') // Always 320px
+```
+
+### 5. Handle Empty Regions Gracefully
+
+```php
+// Regions without content don't render
+$layout = Layout::make('LCR')
+ ->l('Nav ')
+ ->c('Content ');
+ // No ->r() call - right sidebar won't render
+```
+
+## Learn More
+
+- [HLCRF Pattern Overview](/patterns-guide/hlcrf)
+- [Form Components](/packages/admin/forms)
+- [Livewire Modals](/packages/admin/modals)
+- [Creating Admin Panels](/packages/admin/creating-admin-panels)
diff --git a/docs/packages/admin/index.md b/docs/packages/admin/index.md
new file mode 100644
index 0000000..d2c0527
--- /dev/null
+++ b/docs/packages/admin/index.md
@@ -0,0 +1,327 @@
+# Admin Package
+
+The Admin package provides a complete admin panel with Livewire modals, HLCRF layouts, form components, global search, and an extensible menu system.
+
+## Installation
+
+```bash
+composer require host-uk/core-admin
+```
+
+## Quick Start
+
+```php
+ 'onAdminPanel',
+ ];
+
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ // Register admin menu
+ $event->menu(new BlogMenuProvider());
+
+ // Register routes
+ $event->routes(fn () => require __DIR__.'/Routes/admin.php');
+ }
+}
+```
+
+## Key Features
+
+### User Interface
+
+- **[HLCRF Layouts](/packages/admin/hlcrf)** - Composable layout system for admin interfaces
+- **[Livewire Modals](/packages/admin/modals)** - Full-page modal system for forms and details
+- **[Form Components](/packages/admin/forms)** - Pre-built form inputs with validation
+- **[Admin Menus](/packages/admin/menus)** - Extensible navigation menu system
+
+### Search & Discovery
+
+- **[Global Search](/packages/admin/search)** - Unified search across all modules
+- **[Search Providers](/packages/admin/search#providers)** - Register searchable resources
+
+### Components
+
+- **[Data Tables](/packages/admin/tables)** - Sortable, filterable data tables
+- **[Cards & Grids](/packages/admin/components#cards)** - Stat cards and grid layouts
+- **[Buttons & Actions](/packages/admin/components#buttons)** - Action buttons with authorization
+
+### Features
+
+- **[Honeypot Protection](/packages/admin/security)** - Bot detection and logging
+- **[Activity Feeds](/packages/admin/activity)** - Display recent activity logs
+- **[Form Validation](/packages/admin/forms#validation)** - Client and server-side validation
+
+## Components Overview
+
+### Form Components
+
+```blade
+
+
+
+
+
+Save
+```
+
+[Learn more about Forms →](/packages/admin/forms)
+
+### Layout Components
+
+```blade
+
+
+ Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+[Learn more about HLCRF Layouts →](/packages/admin/hlcrf)
+
+## Admin Routes
+
+```php
+// Routes/admin.php
+use Mod\Blog\View\Modal\Admin\PostEditor;
+use Mod\Blog\View\Modal\Admin\PostsList;
+
+Route::middleware(['web', 'auth', 'admin'])->prefix('admin')->group(function () {
+ // Livewire modal routes
+ Route::get('/posts', PostsList::class)->name('admin.blog.posts');
+ Route::get('/posts/create', PostEditor::class)->name('admin.blog.posts.create');
+ Route::get('/posts/{post}/edit', PostEditor::class)->name('admin.blog.posts.edit');
+});
+```
+
+## Livewire Modals
+
+Create full-page modals for admin interfaces:
+
+```php
+ 'required|max:255',
+ 'content' => 'required',
+ ];
+
+ public function mount(?Post $post = null): void
+ {
+ $this->post = $post;
+ $this->title = $post?->title ?? '';
+ $this->content = $post?->content ?? '';
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+
+ if ($this->post) {
+ $this->post->update($validated);
+ } else {
+ Post::create($validated);
+ }
+
+ $this->dispatch('post-saved');
+ $this->redirect(route('admin.blog.posts'));
+ }
+
+ public function render()
+ {
+ return view('blog::admin.post-editor');
+ }
+}
+```
+
+[Learn more about Livewire Modals →](/packages/admin/modals)
+
+## Global Search
+
+Register searchable resources:
+
+```php
+limit(5)
+ ->get()
+ ->map(fn (Post $post) => new SearchResult(
+ title: $post->title,
+ description: $post->excerpt,
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text',
+ category: 'Blog Posts'
+ ))
+ ->toArray();
+ }
+
+ public function getCategory(): string
+ {
+ return 'Blog';
+ }
+}
+```
+
+Register in your Boot.php:
+
+```php
+public function onAdminPanel(AdminPanelBooting $event): void
+{
+ $event->search(new PostSearchProvider());
+}
+```
+
+[Learn more about Search →](/packages/admin/search)
+
+## Configuration
+
+```php
+// config/admin.php
+return [
+ 'middleware' => ['web', 'auth', 'admin'],
+ 'prefix' => 'admin',
+
+ 'menu' => [
+ 'auto_discover' => true,
+ 'cache_enabled' => true,
+ ],
+
+ 'search' => [
+ 'enabled' => true,
+ 'min_length' => 2,
+ 'limit' => 10,
+ ],
+
+ 'honeypot' => [
+ 'enabled' => true,
+ 'field_name' => env('HONEYPOT_FIELD', 'website'),
+ ],
+];
+```
+
+## Middleware
+
+The admin panel uses these middleware by default:
+
+- `web` - Web routes, sessions, CSRF
+- `auth` - Require authentication
+- `admin` - Check user is admin (gates/policies)
+
+## Best Practices
+
+### 1. Use Livewire Modals for Forms
+
+```php
+// ✅ Good - Livewire modal
+Route::get('/posts/create', PostEditor::class);
+
+// ❌ Bad - Traditional controller
+Route::get('/posts/create', [PostController::class, 'create']);
+```
+
+### 2. Use Form Components
+
+```blade
+{{-- ✅ Good - consistent styling --}}
+
+
+{{-- ❌ Bad - custom HTML --}}
+
+```
+
+### 3. Register Search Providers
+
+```php
+// ✅ Good - searchable resources
+$event->search(new PostSearchProvider());
+$event->search(new CategorySearchProvider());
+```
+
+### 4. Use HLCRF for Layouts
+
+```blade
+{{-- ✅ Good - composable layout --}}
+
+ Header
+ Content
+
+```
+
+## Testing
+
+```php
+admin()->create();
+
+ $this->actingAs($admin)
+ ->livewire(PostEditor::class)
+ ->set('title', 'Test Post')
+ ->set('content', 'Test content')
+ ->call('save')
+ ->assertRedirect(route('admin.blog.posts'));
+
+ $this->assertDatabaseHas('posts', [
+ 'title' => 'Test Post',
+ ]);
+ }
+}
+```
+
+## Learn More
+
+- [HLCRF Layouts →](/packages/admin/hlcrf)
+- [Livewire Modals →](/packages/admin/modals)
+- [Form Components →](/packages/admin/forms)
+- [Admin Menus →](/packages/admin/menus)
+- [Global Search →](/packages/admin/search)
diff --git a/docs/packages/admin/menus.md b/docs/packages/admin/menus.md
new file mode 100644
index 0000000..5bd405d
--- /dev/null
+++ b/docs/packages/admin/menus.md
@@ -0,0 +1,234 @@
+# Admin Menus
+
+The Admin package provides an extensible menu system with automatic discovery, authorization, and icon support.
+
+## Creating Menu Providers
+
+```php
+icon('newspaper')
+ ->priority(30)
+ ->children([
+ MenuItemBuilder::make('Posts')
+ ->route('admin.blog.posts.index')
+ ->icon('document-text')
+ ->badge(fn () => Post::draft()->count()),
+
+ MenuItemBuilder::make('Categories')
+ ->route('admin.blog.categories.index')
+ ->icon('folder'),
+
+ MenuItemBuilder::make('Tags')
+ ->route('admin.blog.tags.index')
+ ->icon('tag'),
+ ])
+ ->build(),
+ ];
+ }
+}
+```
+
+## Registering Menus
+
+```php
+// In your Boot.php
+public function onAdminPanel(AdminPanelBooting $event): void
+{
+ $event->menu(new BlogMenuProvider());
+}
+```
+
+## Menu Item Properties
+
+### Basic Item
+
+```php
+MenuItemBuilder::make('Dashboard')
+ ->route('admin.dashboard')
+ ->icon('home')
+ ->build();
+```
+
+### With URL
+
+```php
+MenuItemBuilder::make('External Link')
+ ->url('https://example.com')
+ ->icon('external-link')
+ ->external() // Opens in new tab
+ ->build();
+```
+
+### With Children
+
+```php
+MenuItemBuilder::make('Content')
+ ->icon('document')
+ ->children([
+ MenuItemBuilder::make('Posts')->route('admin.posts'),
+ MenuItemBuilder::make('Pages')->route('admin.pages'),
+ ])
+ ->build();
+```
+
+### With Badge
+
+```php
+MenuItemBuilder::make('Comments')
+ ->route('admin.comments')
+ ->badge(fn () => Comment::pending()->count())
+ ->badgeColor('red')
+ ->build();
+```
+
+### With Authorization
+
+```php
+MenuItemBuilder::make('Settings')
+ ->route('admin.settings')
+ ->can('admin.settings.view')
+ ->build();
+```
+
+### With Priority
+
+```php
+// Higher priority = appears first
+MenuItemBuilder::make('Dashboard')
+ ->priority(100)
+ ->build();
+
+MenuItemBuilder::make('Settings')
+ ->priority(10)
+ ->build();
+```
+
+## Advanced Examples
+
+### Dynamic Menu Based on Permissions
+
+```php
+public function register(): array
+{
+ $menu = MenuItemBuilder::make('Blog')->icon('newspaper');
+
+ if (Gate::allows('posts.view')) {
+ $menu->child(MenuItemBuilder::make('Posts')->route('admin.blog.posts'));
+ }
+
+ if (Gate::allows('categories.view')) {
+ $menu->child(MenuItemBuilder::make('Categories')->route('admin.blog.categories'));
+ }
+
+ return [$menu->build()];
+}
+```
+
+### Menu with Active State
+
+```php
+MenuItemBuilder::make('Posts')
+ ->route('admin.blog.posts')
+ ->active(fn () => request()->routeIs('admin.blog.posts.*'))
+ ->build();
+```
+
+### Menu with Count Badge
+
+```php
+MenuItemBuilder::make('Pending Reviews')
+ ->route('admin.reviews.pending')
+ ->badge(fn () => Review::pending()->count())
+ ->badgeColor('yellow')
+ ->badgeTooltip('Reviews awaiting moderation')
+ ->build();
+```
+
+## Menu Groups
+
+Organize related items:
+
+```php
+MenuItemBuilder::makeGroup('Content Management')
+ ->priority(50)
+ ->children([
+ MenuItemBuilder::make('Posts')->route('admin.posts'),
+ MenuItemBuilder::make('Pages')->route('admin.pages'),
+ MenuItemBuilder::make('Media')->route('admin.media'),
+ ])
+ ->build();
+```
+
+## Icon Support
+
+Menus support Heroicons:
+
+```php
+->icon('document-text') // Document icon
+->icon('users') // Users icon
+->icon('cog') // Settings icon
+->icon('chart-bar') // Analytics icon
+```
+
+[Browse Heroicons →](https://heroicons.com)
+
+## Best Practices
+
+### 1. Use Meaningful Icons
+
+```php
+// ✅ Good - clear icon
+MenuItemBuilder::make('Posts')->icon('document-text')
+
+// ❌ Bad - generic icon
+MenuItemBuilder::make('Posts')->icon('square')
+```
+
+### 2. Set Priorities
+
+```php
+// ✅ Good - logical ordering
+MenuItemBuilder::make('Dashboard')->priority(100)
+MenuItemBuilder::make('Posts')->priority(90)
+MenuItemBuilder::make('Settings')->priority(10)
+```
+
+### 3. Use Authorization
+
+```php
+// ✅ Good - respects permissions
+MenuItemBuilder::make('Settings')
+ ->can('admin.settings.view')
+```
+
+### 4. Keep Hierarchy Shallow
+
+```php
+// ✅ Good - 2 levels max
+Blog
+ ├─ Posts
+ └─ Categories
+
+// ❌ Bad - too deep
+Content
+ └─ Blog
+ └─ Posts
+ └─ Published
+```
+
+## Learn More
+
+- [Authorization →](/packages/admin/authorization)
+- [Livewire Modals →](/packages/admin/modals)
diff --git a/docs/packages/admin/modals.md b/docs/packages/admin/modals.md
new file mode 100644
index 0000000..077ec92
--- /dev/null
+++ b/docs/packages/admin/modals.md
@@ -0,0 +1,577 @@
+# Livewire Modals
+
+The Admin package uses Livewire components as full-page modals, providing a seamless admin interface without traditional page reloads.
+
+## Overview
+
+Livewire modals in Core PHP:
+- Render as full-page routes
+- Support direct URL access
+- Maintain browser history
+- Work with back/forward buttons
+- No JavaScript modal libraries needed
+
+## Creating a Modal
+
+### Basic Modal
+
+```php
+ 'required|max:255',
+ 'content' => 'required',
+ 'status' => 'required|in:draft,published',
+ ];
+
+ public function mount(?Post $post = null): void
+ {
+ $this->post = $post;
+
+ if ($post) {
+ $this->title = $post->title;
+ $this->content = $post->content;
+ $this->status = $post->status;
+ }
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+
+ if ($this->post) {
+ $this->post->update($validated);
+ $message = 'Post updated successfully';
+ } else {
+ Post::create($validated);
+ $message = 'Post created successfully';
+ }
+
+ session()->flash('success', $message);
+ $this->redirect(route('admin.blog.posts'));
+ }
+
+ public function render()
+ {
+ return view('blog::admin.post-editor')
+ ->layout('admin::layouts.modal');
+ }
+}
+```
+
+### Modal View
+
+```blade
+{{-- resources/views/admin/post-editor.blade.php --}}
+
+
+
+
{{ $post ? 'Edit Post' : 'Create Post' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Publishing Tips
+
+ Write a clear, descriptive title
+ Use proper formatting in content
+ Save as draft to preview first
+
+
+
+
+```
+
+## Registering Modal Routes
+
+```php
+// Routes/admin.php
+use Mod\Blog\View\Modal\Admin\PostEditor;
+use Mod\Blog\View\Modal\Admin\PostsList;
+
+Route::middleware(['web', 'auth', 'admin'])->prefix('admin/blog')->group(function () {
+ Route::get('/posts', PostsList::class)->name('admin.blog.posts');
+ Route::get('/posts/create', PostEditor::class)->name('admin.blog.posts.create');
+ Route::get('/posts/{post}/edit', PostEditor::class)->name('admin.blog.posts.edit');
+});
+```
+
+## Opening Modals
+
+### Via Link
+
+```blade
+
+ New Post
+
+```
+
+### Via Livewire Navigate
+
+```blade
+
+ New Post
+
+```
+
+### Via JavaScript
+
+```blade
+
+ New Post
+
+```
+
+## Modal Layouts
+
+### With HLCRF
+
+```blade
+
+
+ Modal Header
+
+
+
+ Modal Content
+
+
+
+ Modal Footer
+
+
+```
+
+### Full-Width Modal
+
+```blade
+
+
+ Full-width content
+
+
+```
+
+### With Sidebar
+
+```blade
+
+
+ Main content
+
+
+
+ Sidebar
+
+
+```
+
+## Advanced Patterns
+
+### Modal with Confirmation
+
+```php
+public bool $showDeleteConfirmation = false;
+
+public function confirmDelete(): void
+{
+ $this->showDeleteConfirmation = true;
+}
+
+public function delete(): void
+{
+ $this->post->delete();
+
+ session()->flash('success', 'Post deleted');
+ $this->redirect(route('admin.blog.posts'));
+}
+
+public function cancelDelete(): void
+{
+ $this->showDeleteConfirmation = false;
+}
+```
+
+```blade
+@if($showDeleteConfirmation)
+
+
+
Delete Post?
+
This action cannot be undone.
+
+
+
+ Delete
+
+
+ Cancel
+
+
+
+
+@endif
+```
+
+### Modal with Steps
+
+```php
+public int $step = 1;
+
+public function nextStep(): void
+{
+ $this->validateOnly('step' . $this->step);
+ $this->step++;
+}
+
+public function previousStep(): void
+{
+ $this->step--;
+}
+```
+
+```blade
+
+ @if($step === 1)
+ {{-- Step 1: Basic Info --}}
+
+
Next
+ @elseif($step === 2)
+ {{-- Step 2: Content --}}
+
+
Back
+
Next
+ @else
+ {{-- Step 3: Review --}}
+
Review and save...
+
Back
+
Save
+ @endif
+
+```
+
+### Modal with Live Search
+
+```php
+public string $search = '';
+public array $results = [];
+
+public function updatedSearch(): void
+{
+ $this->results = Post::where('title', 'like', "%{$this->search}%")
+ ->limit(10)
+ ->get()
+ ->toArray();
+}
+```
+
+```blade
+
+
+
+ @foreach($results as $result)
+
+ {{ $result['title'] }}
+
+ @endforeach
+
+```
+
+## File Uploads
+
+### Single File
+
+```php
+use Livewire\WithFileUploads;
+
+class PostEditor extends Component
+{
+ use WithFileUploads;
+
+ public $image;
+
+ public function save(): void
+ {
+ $this->validate([
+ 'image' => 'required|image|max:2048',
+ ]);
+
+ $path = $this->image->store('posts', 'public');
+
+ Post::create([
+ 'image_path' => $path,
+ ]);
+ }
+}
+```
+
+```blade
+
+
+
+ @if($image)
+
+ @endif
+
+```
+
+### Multiple Files
+
+```php
+public array $images = [];
+
+public function save(): void
+{
+ $this->validate([
+ 'images.*' => 'image|max:2048',
+ ]);
+
+ foreach ($this->images as $image) {
+ $path = $image->store('posts', 'public');
+ // Save path...
+ }
+}
+```
+
+## Real-Time Validation
+
+```php
+protected array $rules = [
+ 'title' => 'required|max:255',
+ 'slug' => 'required|unique:posts,slug',
+];
+
+public function updated($propertyName): void
+{
+ $this->validateOnly($propertyName);
+}
+```
+
+```blade
+
+
+@error('slug')
+ {{ $message }}
+@enderror
+```
+
+## Loading States
+
+```blade
+{{-- Show loading on specific action --}}
+
+ Save
+ Saving...
+
+
+{{-- Disable form during loading --}}
+
+
+{{-- Spinner --}}
+
+```
+
+## Events
+
+### Dispatch Events
+
+```php
+// From modal
+public function save(): void
+{
+ // Save logic...
+
+ $this->dispatch('post-saved', postId: $post->id);
+}
+```
+
+### Listen to Events
+
+```php
+// In another component
+protected $listeners = ['post-saved' => 'refreshPosts'];
+
+public function refreshPosts(int $postId): void
+{
+ $this->posts = Post::all();
+}
+```
+
+```blade
+{{-- In Blade --}}
+
+
+```
+
+## Best Practices
+
+### 1. Use Route Model Binding
+
+```php
+// ✅ Good - automatic model resolution
+Route::get('/posts/{post}/edit', PostEditor::class);
+
+public function mount(?Post $post = null): void
+{
+ $this->post = $post;
+}
+```
+
+### 2. Flash Messages
+
+```php
+// ✅ Good - inform user of success
+public function save(): void
+{
+ // Save logic...
+
+ session()->flash('success', 'Post saved');
+ $this->redirect(route('admin.blog.posts'));
+}
+```
+
+### 3. Validate Early
+
+```php
+// ✅ Good - real-time validation
+public function updated($propertyName): void
+{
+ $this->validateOnly($propertyName);
+}
+```
+
+### 4. Use Loading States
+
+```blade
+{{-- ✅ Good - show loading feedback --}}
+
+ Save
+
+```
+
+## Testing
+
+```php
+set('title', 'Test Post')
+ ->set('content', 'Test content')
+ ->set('status', 'published')
+ ->call('save')
+ ->assertRedirect(route('admin.blog.posts'));
+
+ $this->assertDatabaseHas('posts', [
+ 'title' => 'Test Post',
+ ]);
+ }
+
+ public function test_validates_required_fields(): void
+ {
+ Livewire::test(PostEditor::class)
+ ->set('title', '')
+ ->call('save')
+ ->assertHasErrors(['title' => 'required']);
+ }
+
+ public function test_updates_existing_post(): void
+ {
+ $post = Post::factory()->create();
+
+ Livewire::test(PostEditor::class, ['post' => $post])
+ ->set('title', 'Updated Title')
+ ->call('save')
+ ->assertRedirect();
+
+ $this->assertEquals('Updated Title', $post->fresh()->title);
+ }
+}
+```
+
+## Learn More
+
+- [Form Components →](/packages/admin/forms)
+- [HLCRF Layouts →](/packages/admin/hlcrf)
+- [Livewire Documentation →](https://livewire.laravel.com)
diff --git a/docs/packages/admin/search.md b/docs/packages/admin/search.md
new file mode 100644
index 0000000..bc40978
--- /dev/null
+++ b/docs/packages/admin/search.md
@@ -0,0 +1,434 @@
+# Global Search
+
+The Admin package provides a unified global search system that searches across all registered modules and resources.
+
+## Overview
+
+Global search features:
+- Search across multiple modules
+- Keyboard shortcut (Cmd/Ctrl + K)
+- Real-time results
+- Category grouping
+- Icon support
+- Direct navigation
+
+## Registering Search Providers
+
+### Basic Search Provider
+
+```php
+limit(5)
+ ->get()
+ ->map(fn (Post $post) => new SearchResult(
+ title: $post->title,
+ description: $post->excerpt,
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text',
+ category: 'Blog Posts'
+ ))
+ ->toArray();
+ }
+
+ public function getCategory(): string
+ {
+ return 'Blog';
+ }
+
+ public function getPriority(): int
+ {
+ return 50; // Higher = appears first
+ }
+}
+```
+
+### Register in Boot.php
+
+```php
+ 'onAdminPanel',
+ ];
+
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ $event->search(new PostSearchProvider());
+ }
+}
+```
+
+## Search Result
+
+The `SearchResult` class defines how results appear:
+
+```php
+use Core\Admin\Search\SearchResult;
+
+new SearchResult(
+ title: 'My Blog Post', // Required
+ description: 'This is a blog post about...', // Optional
+ url: route('admin.blog.posts.edit', $post), // Required
+ icon: 'document-text', // Optional
+ category: 'Blog Posts', // Optional
+ metadata: [ // Optional
+ 'Status' => 'Published',
+ 'Author' => $post->author->name,
+ ]
+);
+```
+
+**Properties:**
+- `title` (string, required) - Primary title
+- `description` (string, optional) - Subtitle/excerpt
+- `url` (string, required) - Link URL
+- `icon` (string, optional) - Heroicon name
+- `category` (string, optional) - Result category
+- `metadata` (array, optional) - Additional key-value pairs
+
+## Advanced Search Providers
+
+### With Highlighting
+
+```php
+public function search(string $query): array
+{
+ return Post::where('title', 'like', "%{$query}%")
+ ->get()
+ ->map(function (Post $post) use ($query) {
+ // Highlight matching text
+ $title = str_ireplace(
+ $query,
+ "{$query} ",
+ $post->title
+ );
+
+ return new SearchResult(
+ title: $title,
+ description: $post->excerpt,
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text'
+ );
+ })
+ ->toArray();
+}
+```
+
+### Multi-Field Search
+
+```php
+public function search(string $query): array
+{
+ return Post::where(function ($q) use ($query) {
+ $q->where('title', 'like', "%{$query}%")
+ ->orWhere('content', 'like', "%{$query}%")
+ ->orWhere('slug', 'like', "%{$query}%");
+ })
+ ->limit(5)
+ ->get()
+ ->map(fn ($post) => new SearchResult(
+ title: $post->title,
+ description: "Slug: {$post->slug}",
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text',
+ category: 'Posts'
+ ))
+ ->toArray();
+}
+```
+
+### With Relevance Scoring
+
+```php
+public function search(string $query): array
+{
+ $posts = Post::selectRaw("
+ *,
+ CASE
+ WHEN title LIKE ? THEN 3
+ WHEN excerpt LIKE ? THEN 2
+ WHEN content LIKE ? THEN 1
+ ELSE 0
+ END as relevance
+ ", ["%{$query}%", "%{$query}%", "%{$query}%"])
+ ->having('relevance', '>', 0)
+ ->orderBy('relevance', 'desc')
+ ->limit(5)
+ ->get();
+
+ return $posts->map(fn ($post) => new SearchResult(
+ title: $post->title,
+ description: $post->excerpt,
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text'
+ ))->toArray();
+}
+```
+
+### Search with Relationships
+
+```php
+public function search(string $query): array
+{
+ return Post::with('author', 'category')
+ ->where('title', 'like', "%{$query}%")
+ ->limit(5)
+ ->get()
+ ->map(fn ($post) => new SearchResult(
+ title: $post->title,
+ description: $post->excerpt,
+ url: route('admin.blog.posts.edit', $post),
+ icon: 'document-text',
+ category: 'Posts',
+ metadata: [
+ 'Author' => $post->author->name,
+ 'Category' => $post->category->name,
+ 'Status' => ucfirst($post->status),
+ ]
+ ))
+ ->toArray();
+}
+```
+
+## Search Analytics
+
+Track search queries:
+
+```php
+analytics->recordSearch($query, 'admin', 'posts');
+
+ $results = Post::where('title', 'like', "%{$query}%")
+ ->limit(5)
+ ->get();
+
+ // Record result count
+ $this->analytics->recordResults($query, $results->count());
+
+ return $results->map(fn ($post) => new SearchResult(
+ title: $post->title,
+ url: route('admin.blog.posts.edit', $post)
+ ))->toArray();
+ }
+}
+```
+
+## Multiple Providers
+
+Register multiple providers for different resources:
+
+```php
+public function onAdminPanel(AdminPanelBooting $event): void
+{
+ $event->search(new PostSearchProvider());
+ $event->search(new CategorySearchProvider());
+ $event->search(new CommentSearchProvider());
+}
+```
+
+Each provider returns results independently, grouped by category.
+
+## Search UI
+
+The global search is accessible via:
+
+### Keyboard Shortcut
+
+Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to open search from anywhere in the admin panel.
+
+### Search Button
+
+Click the search icon in the admin header.
+
+### Direct URL
+
+Navigate to `/admin/search?q=query`.
+
+## Configuration
+
+```php
+// config/admin.php
+'search' => [
+ 'enabled' => true,
+ 'min_length' => 2, // Minimum query length
+ 'limit' => 10, // Results per provider
+ 'debounce' => 300, // Debounce delay (ms)
+ 'show_empty_results' => true,
+ 'shortcuts' => [
+ 'mac' => 'cmd+k',
+ 'windows' => 'ctrl+k',
+ ],
+],
+```
+
+## Search Suggestions
+
+Provide autocomplete suggestions:
+
+```php
+public function getSuggestions(string $query): array
+{
+ return Post::where('title', 'like', "{$query}%")
+ ->limit(5)
+ ->pluck('title')
+ ->toArray();
+}
+```
+
+## Empty State
+
+Customize empty search results:
+
+```php
+public function getEmptyMessage(string $query): string
+{
+ return "No posts found matching '{$query}'. Try a different search term.";
+}
+
+public function getEmptyActions(): array
+{
+ return [
+ [
+ 'label' => 'Create New Post',
+ 'url' => route('admin.blog.posts.create'),
+ 'icon' => 'plus',
+ ],
+ ];
+}
+```
+
+## Best Practices
+
+### 1. Limit Results
+
+```php
+// ✅ Good - limit results
+return Post::where('title', 'like', "%{$query}%")
+ ->limit(5)
+ ->get();
+
+// ❌ Bad - return all results
+return Post::where('title', 'like', "%{$query}%")->get();
+```
+
+### 2. Use Indexes
+
+```php
+// ✅ Good - indexed column
+Schema::table('posts', function (Blueprint $table) {
+ $table->index('title');
+});
+```
+
+### 3. Search Multiple Fields
+
+```php
+// ✅ Good - comprehensive search
+Post::where('title', 'like', "%{$query}%")
+ ->orWhere('excerpt', 'like', "%{$query}%")
+ ->orWhere('slug', 'like', "%{$query}%");
+```
+
+### 4. Include Context in Results
+
+```php
+// ✅ Good - helpful metadata
+new SearchResult(
+ title: $post->title,
+ description: $post->excerpt,
+ metadata: [
+ 'Author' => $post->author->name,
+ 'Date' => $post->created_at->format('M d, Y'),
+ ]
+);
+```
+
+### 5. Set Priority
+
+```php
+// ✅ Good - important resources first
+public function getPriority(): int
+{
+ return 100; // Posts appear before comments
+}
+```
+
+## Testing
+
+```php
+create(['title' => 'Laravel Framework']);
+ Post::factory()->create(['title' => 'Vue.js Guide']);
+
+ $provider = new PostSearchProvider();
+ $results = $provider->search('Laravel');
+
+ $this->assertCount(1, $results);
+ $this->assertEquals('Laravel Framework', $results[0]->title);
+ }
+
+ public function test_limits_results(): void
+ {
+ Post::factory()->count(10)->create([
+ 'title' => 'Test Post',
+ ]);
+
+ $provider = new PostSearchProvider();
+ $results = $provider->search('Test');
+
+ $this->assertLessThanOrEqual(5, count($results));
+ }
+}
+```
+
+## Learn More
+
+- [Search Analytics →](/packages/core/search)
+- [Admin Menus →](/packages/admin/menus)
+- [Livewire Components →](/packages/admin/modals)
diff --git a/docs/packages/api/authentication.md b/docs/packages/api/authentication.md
new file mode 100644
index 0000000..3fe97ce
--- /dev/null
+++ b/docs/packages/api/authentication.md
@@ -0,0 +1,391 @@
+# API Authentication
+
+The API package provides secure authentication with bcrypt-hashed API keys, scope-based permissions, and automatic key rotation.
+
+## API Key Management
+
+### Creating Keys
+
+```php
+use Mod\Api\Models\ApiKey;
+
+$apiKey = ApiKey::create([
+ 'name' => 'Mobile App Production',
+ 'workspace_id' => $workspace->id,
+ 'scopes' => ['posts:read', 'posts:write', 'categories:read'],
+ 'rate_limit_tier' => 'pro',
+ 'expires_at' => now()->addYear(),
+]);
+
+// Get plaintext key (only available once!)
+$plaintext = $apiKey->plaintext_key;
+// Returns: sk_live_abc123def456...
+```
+
+**Key Format:** `{prefix}_{environment}_{random}`
+- Prefix: `sk` (secret key)
+- Environment: `live` or `test`
+- Random: 32-character string
+
+### Secure Storage
+
+Keys are hashed with bcrypt before storage:
+
+```php
+// Never stored in plaintext
+$hash = bcrypt($plaintext);
+
+// Stored in database
+$apiKey->key_hash = $hash;
+
+// Verification
+if (Hash::check($providedKey, $apiKey->key_hash)) {
+ // Valid key
+}
+```
+
+### Key Rotation
+
+Rotate keys with a grace period:
+
+```php
+$newKey = $apiKey->rotate([
+ 'grace_period_hours' => 24,
+]);
+
+// Returns new ApiKey with:
+// - New plaintext key
+// - Same scopes and settings
+// - Old key marked for deletion after grace period
+```
+
+During the grace period, both keys work. After 24 hours, the old key is automatically deleted.
+
+## Using API Keys
+
+### Authorization Header
+
+```bash
+curl -H "Authorization: Bearer sk_live_abc123..." \
+ https://api.example.com/v1/posts
+```
+
+### Basic Auth
+
+```bash
+curl -u sk_live_abc123: \
+ https://api.example.com/v1/posts
+```
+
+### PHP Example
+
+```php
+use GuzzleHttp\Client;
+
+$client = new Client([
+ 'base_uri' => 'https://api.example.com',
+ 'headers' => [
+ 'Authorization' => "Bearer {$apiKey}",
+ 'Accept' => 'application/json',
+ ],
+]);
+
+$response = $client->get('/v1/posts');
+```
+
+### JavaScript Example
+
+```javascript
+const response = await fetch('https://api.example.com/v1/posts', {
+ headers: {
+ 'Authorization': `Bearer ${apiKey}`,
+ 'Accept': 'application/json'
+ }
+});
+```
+
+## Scopes & Permissions
+
+### Defining Scopes
+
+```php
+$apiKey = ApiKey::create([
+ 'scopes' => [
+ 'posts:read', // Read posts
+ 'posts:write', // Create/update posts
+ 'posts:delete', // Delete posts
+ 'categories:read', // Read categories
+ ],
+]);
+```
+
+### Common Scopes
+
+| Scope | Description |
+|-------|-------------|
+| `{resource}:read` | Read access |
+| `{resource}:write` | Create and update |
+| `{resource}:delete` | Delete access |
+| `{resource}:*` | All permissions for resource |
+| `*` | Full access (use sparingly!) |
+
+### Wildcard Scopes
+
+```php
+// All post permissions
+'scopes' => ['posts:*']
+
+// Read access to all resources
+'scopes' => ['*:read']
+
+// Full access (admin only!)
+'scopes' => ['*']
+```
+
+### Scope Enforcement
+
+Protect routes with scope middleware:
+
+```php
+Route::middleware('scope:posts:write')
+ ->post('/posts', [PostController::class, 'store']);
+
+Route::middleware('scope:posts:delete')
+ ->delete('/posts/{id}', [PostController::class, 'destroy']);
+```
+
+### Check Scopes in Controllers
+
+```php
+public function store(Request $request)
+{
+ if (!$request->user()->tokenCan('posts:write')) {
+ return response()->json([
+ 'error' => 'Insufficient permissions',
+ 'required_scope' => 'posts:write',
+ ], 403);
+ }
+
+ return Post::create($request->validated());
+}
+```
+
+## Rate Limiting
+
+Keys are rate-limited based on tier:
+
+```php
+// config/api.php
+'rate_limits' => [
+ 'free' => ['requests' => 1000, 'per' => 'hour'],
+ 'pro' => ['requests' => 10000, 'per' => 'hour'],
+ 'business' => ['requests' => 50000, 'per' => 'hour'],
+ 'enterprise' => ['requests' => null], // Unlimited
+],
+```
+
+Rate limit headers included in responses:
+
+```
+X-RateLimit-Limit: 10000
+X-RateLimit-Remaining: 9847
+X-RateLimit-Reset: 1640995200
+```
+
+[Learn more about Rate Limiting →](/packages/api/rate-limiting)
+
+## Key Expiration
+
+### Set Expiration
+
+```php
+$apiKey = ApiKey::create([
+ 'expires_at' => now()->addMonths(6),
+]);
+```
+
+### Check Expiration
+
+```php
+if ($apiKey->isExpired()) {
+ return response()->json(['error' => 'API key expired'], 401);
+}
+```
+
+### Auto-Cleanup
+
+Expired keys are automatically cleaned up:
+
+```bash
+php artisan api:prune-expired-keys
+```
+
+## Environment-Specific Keys
+
+### Test Keys
+
+```php
+$testKey = ApiKey::create([
+ 'name' => 'Development Key',
+ 'environment' => 'test',
+]);
+
+// Key prefix: sk_test_...
+```
+
+Test keys:
+- Don't affect production data
+- Higher rate limits
+- Clearly marked in UI
+- Easy to identify and delete
+
+### Live Keys
+
+```php
+$liveKey = ApiKey::create([
+ 'environment' => 'live',
+]);
+
+// Key prefix: sk_live_...
+```
+
+## Middleware
+
+### API Authentication
+
+```php
+Route::middleware('auth:api')->group(function () {
+ // Protected routes
+});
+```
+
+### Scope Enforcement
+
+```php
+use Mod\Api\Middleware\EnforceApiScope;
+
+Route::middleware([EnforceApiScope::class.':posts:write'])
+ ->post('/posts', [PostController::class, 'store']);
+```
+
+### Rate Limiting
+
+```php
+use Mod\Api\Middleware\RateLimitApi;
+
+Route::middleware(RateLimitApi::class)->group(function () {
+ // Rate-limited routes
+});
+```
+
+## Security Best Practices
+
+### 1. Minimum Required Scopes
+
+```php
+// ✅ Good - specific scopes
+'scopes' => ['posts:read', 'categories:read']
+
+// ❌ Bad - excessive permissions
+'scopes' => ['*']
+```
+
+### 2. Rotate Regularly
+
+```php
+// Rotate every 90 days
+if ($apiKey->created_at->diffInDays() > 90) {
+ $newKey = $apiKey->rotate();
+ // Notify user of new key
+}
+```
+
+### 3. Use Separate Keys Per Client
+
+```php
+// ✅ Good - separate keys
+ApiKey::create(['name' => 'iOS App']);
+ApiKey::create(['name' => 'Android App']);
+ApiKey::create(['name' => 'Web App']);
+
+// ❌ Bad - shared key
+ApiKey::create(['name' => 'All Mobile Apps']);
+```
+
+### 4. Set Expiration
+
+```php
+// ✅ Good - temporary access
+'expires_at' => now()->addMonths(6)
+
+// ❌ Bad - never expires
+'expires_at' => null
+```
+
+### 5. Monitor Usage
+
+```php
+$usage = ApiKey::find($id)->usage()
+ ->whereBetween('created_at', [now()->subDays(7), now()])
+ ->count();
+
+if ($usage > $threshold) {
+ // Alert admin
+}
+```
+
+## Testing
+
+```php
+create([
+ 'scopes' => ['posts:read'],
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->getJson('/api/v1/posts');
+
+ $response->assertOk();
+ }
+
+ public function test_rejects_invalid_key(): void
+ {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer invalid_key',
+ ])->getJson('/api/v1/posts');
+
+ $response->assertUnauthorized();
+ }
+
+ public function test_enforces_scopes(): void
+ {
+ $apiKey = ApiKey::factory()->create([
+ 'scopes' => ['posts:read'], // No write permission
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->postJson('/api/v1/posts', ['title' => 'Test']);
+
+ $response->assertForbidden();
+ }
+}
+```
+
+## Learn More
+
+- [Rate Limiting →](/packages/api/rate-limiting)
+- [Scopes →](/packages/api/scopes)
+- [Webhooks →](/packages/api/webhooks)
+- [API Reference →](/api/authentication)
diff --git a/docs/packages/api/building-rest-apis.md b/docs/packages/api/building-rest-apis.md
new file mode 100644
index 0000000..8eb52ea
--- /dev/null
+++ b/docs/packages/api/building-rest-apis.md
@@ -0,0 +1,898 @@
+# Building REST APIs
+
+This guide covers how to build production-ready REST APIs using the core-api package. You'll learn to create resources, implement pagination, add filtering and sorting, and secure endpoints with authentication.
+
+## Quick Start
+
+Register API routes by listening to the `ApiRoutesRegistering` event:
+
+```php
+ 'onApiRoutes',
+ ];
+
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ $event->routes(function () {
+ Route::apiResource('posts', Api\PostController::class);
+ });
+ }
+}
+```
+
+## Creating Resources
+
+### API Resources
+
+Transform Eloquent models into consistent JSON responses using Laravel's API Resources:
+
+```php
+ $this->id,
+ 'type' => 'post',
+ 'attributes' => [
+ 'title' => $this->title,
+ 'slug' => $this->slug,
+ 'excerpt' => $this->excerpt,
+ 'content' => $this->when(
+ $request->user()?->tokenCan('posts:read-content'),
+ $this->content
+ ),
+ 'status' => $this->status,
+ 'published_at' => $this->published_at?->toIso8601String(),
+ ],
+ 'relationships' => [
+ 'author' => $this->whenLoaded('author', fn () => [
+ 'id' => $this->author->id,
+ 'name' => $this->author->name,
+ ]),
+ 'categories' => $this->whenLoaded('categories', fn () =>
+ $this->categories->map(fn ($cat) => [
+ 'id' => $cat->id,
+ 'name' => $cat->name,
+ ])
+ ),
+ ],
+ 'meta' => [
+ 'created_at' => $this->created_at->toIso8601String(),
+ 'updated_at' => $this->updated_at->toIso8601String(),
+ ],
+ ];
+ }
+}
+```
+
+### Resource Controllers
+
+Build controllers that use the `HasApiResponses` trait for consistent error handling:
+
+```php
+with(['author', 'categories'])
+ ->paginate($request->input('per_page', 25));
+
+ return new PaginatedCollection($posts, PostResource::class);
+ }
+
+ public function show(Post $post)
+ {
+ $post->load(['author', 'categories']);
+
+ return new PostResource($post);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'title' => 'required|string|max:255',
+ 'content' => 'required|string',
+ 'status' => 'in:draft,published',
+ ]);
+
+ $post = Post::create($validated);
+
+ return $this->createdResponse(
+ new PostResource($post),
+ 'Post created successfully.'
+ );
+ }
+
+ public function update(Request $request, Post $post)
+ {
+ $validated = $request->validate([
+ 'title' => 'string|max:255',
+ 'content' => 'string',
+ 'status' => 'in:draft,published',
+ ]);
+
+ $post->update($validated);
+
+ return new PostResource($post);
+ }
+
+ public function destroy(Post $post)
+ {
+ $post->delete();
+
+ return response()->json(null, 204);
+ }
+}
+```
+
+## Pagination
+
+### Using PaginatedCollection
+
+The `PaginatedCollection` class provides standardized pagination metadata:
+
+```php
+use Core\Mod\Api\Resources\PaginatedCollection;
+
+public function index(Request $request)
+{
+ $posts = Post::paginate(
+ $request->input('per_page', config('api.pagination.default_per_page', 25))
+ );
+
+ return new PaginatedCollection($posts, PostResource::class);
+}
+```
+
+### Response Format
+
+Paginated responses include comprehensive metadata:
+
+```json
+{
+ "data": [
+ {"id": 1, "type": "post", "attributes": {...}},
+ {"id": 2, "type": "post", "attributes": {...}}
+ ],
+ "meta": {
+ "current_page": 1,
+ "from": 1,
+ "last_page": 10,
+ "per_page": 25,
+ "to": 25,
+ "total": 250
+ },
+ "links": {
+ "first": "https://api.example.com/v1/posts?page=1",
+ "last": "https://api.example.com/v1/posts?page=10",
+ "prev": null,
+ "next": "https://api.example.com/v1/posts?page=2"
+ }
+}
+```
+
+### Pagination Best Practices
+
+**1. Limit Maximum Page Size**
+
+```php
+public function index(Request $request)
+{
+ $perPage = min(
+ $request->input('per_page', 25),
+ config('api.pagination.max_per_page', 100)
+ );
+
+ return new PaginatedCollection(
+ Post::paginate($perPage),
+ PostResource::class
+ );
+}
+```
+
+**2. Use Cursor Pagination for Large Datasets**
+
+```php
+public function index(Request $request)
+{
+ $posts = Post::orderBy('id')
+ ->cursorPaginate($request->input('per_page', 25));
+
+ return PostResource::collection($posts);
+}
+```
+
+**3. Include Total Count Conditionally**
+
+For very large tables, counting can be expensive:
+
+```php
+public function index(Request $request)
+{
+ $query = Post::query();
+
+ // Only count if explicitly requested
+ if ($request->boolean('include_total')) {
+ return new PaginatedCollection(
+ $query->paginate($request->input('per_page', 25)),
+ PostResource::class
+ );
+ }
+
+ // Use simple pagination (no total count)
+ return PostResource::collection(
+ $query->simplePaginate($request->input('per_page', 25))
+ );
+}
+```
+
+## Filtering
+
+### Query Parameter Filters
+
+Implement flexible filtering with query parameters:
+
+```php
+public function index(Request $request)
+{
+ $query = Post::query();
+
+ // Status filter
+ if ($status = $request->input('status')) {
+ $query->where('status', $status);
+ }
+
+ // Date range filters
+ if ($after = $request->input('created_after')) {
+ $query->where('created_at', '>=', $after);
+ }
+
+ if ($before = $request->input('created_before')) {
+ $query->where('created_at', '<=', $before);
+ }
+
+ // Author filter
+ if ($authorId = $request->input('author_id')) {
+ $query->where('author_id', $authorId);
+ }
+
+ // Full-text search
+ if ($search = $request->input('search')) {
+ $query->where(function ($q) use ($search) {
+ $q->where('title', 'like', "%{$search}%")
+ ->orWhere('content', 'like', "%{$search}%");
+ });
+ }
+
+ return new PaginatedCollection(
+ $query->paginate($request->input('per_page', 25)),
+ PostResource::class
+ );
+}
+```
+
+### Filter Validation
+
+Validate filter parameters to prevent errors:
+
+```php
+public function index(Request $request)
+{
+ $request->validate([
+ 'status' => 'in:draft,published,archived',
+ 'created_after' => 'date|before_or_equal:created_before',
+ 'created_before' => 'date',
+ 'author_id' => 'integer|exists:users,id',
+ 'per_page' => 'integer|min:1|max:100',
+ ]);
+
+ // Apply filters...
+}
+```
+
+### Reusable Filter Traits
+
+Create a trait for common filtering patterns:
+
+```php
+input('created_after')) {
+ $query->where('created_at', '>=', $after);
+ }
+
+ if ($before = $request->input('created_before')) {
+ $query->where('created_at', '<=', $before);
+ }
+
+ if ($updatedAfter = $request->input('updated_after')) {
+ $query->where('updated_at', '>=', $updatedAfter);
+ }
+
+ // Status filter (if model has status)
+ if ($status = $request->input('status')) {
+ $query->where('status', $status);
+ }
+
+ return $query;
+ }
+}
+```
+
+## Sorting
+
+### Sort Parameter
+
+Implement sorting with a `sort` query parameter:
+
+```php
+public function index(Request $request)
+{
+ $query = Post::query();
+
+ // Parse sort parameter: -created_at,title
+ $sortFields = $this->parseSortFields(
+ $request->input('sort', '-created_at')
+ );
+
+ foreach ($sortFields as $field => $direction) {
+ $query->orderBy($field, $direction);
+ }
+
+ return new PaginatedCollection(
+ $query->paginate($request->input('per_page', 25)),
+ PostResource::class
+ );
+}
+
+protected function parseSortFields(string $sort): array
+{
+ $allowedFields = ['id', 'title', 'created_at', 'updated_at', 'published_at'];
+ $fields = [];
+
+ foreach (explode(',', $sort) as $field) {
+ $direction = 'asc';
+
+ if (str_starts_with($field, '-')) {
+ $direction = 'desc';
+ $field = substr($field, 1);
+ }
+
+ if (in_array($field, $allowedFields)) {
+ $fields[$field] = $direction;
+ }
+ }
+
+ return $fields ?: ['created_at' => 'desc'];
+}
+```
+
+### Sort Validation
+
+Validate sort fields against an allowlist:
+
+```php
+public function index(Request $request)
+{
+ $request->validate([
+ 'sort' => [
+ 'string',
+ 'regex:/^-?(id|title|created_at|updated_at)(,-?(id|title|created_at|updated_at))*$/',
+ ],
+ ]);
+
+ // Apply sorting...
+}
+```
+
+## Authentication
+
+### Protecting Routes
+
+Use the `auth:api` middleware to protect endpoints:
+
+```php
+// In your Boot class
+$event->routes(function () {
+ // Public routes (no authentication)
+ Route::get('/posts', [PostController::class, 'index']);
+ Route::get('/posts/{post}', [PostController::class, 'show']);
+
+ // Protected routes (require authentication)
+ Route::middleware('auth:api')->group(function () {
+ Route::post('/posts', [PostController::class, 'store']);
+ Route::put('/posts/{post}', [PostController::class, 'update']);
+ Route::delete('/posts/{post}', [PostController::class, 'destroy']);
+ });
+});
+```
+
+### Scope-Based Authorization
+
+Enforce API key scopes on routes:
+
+```php
+Route::middleware(['auth:api', 'scope:posts:write'])
+ ->post('/posts', [PostController::class, 'store']);
+
+Route::middleware(['auth:api', 'scope:posts:delete'])
+ ->delete('/posts/{post}', [PostController::class, 'destroy']);
+```
+
+### Checking Scopes in Controllers
+
+Verify scopes programmatically for fine-grained control:
+
+```php
+public function update(Request $request, Post $post)
+{
+ // Check if user can update posts
+ if (!$request->user()->tokenCan('posts:write')) {
+ return $this->accessDeniedResponse('Insufficient permissions to update posts.');
+ }
+
+ // Check if user can publish (requires elevated scope)
+ if ($request->input('status') === 'published') {
+ if (!$request->user()->tokenCan('posts:publish')) {
+ return $this->accessDeniedResponse('Insufficient permissions to publish posts.');
+ }
+ }
+
+ $post->update($request->validated());
+
+ return new PostResource($post);
+}
+```
+
+### API Key Authentication Examples
+
+**PHP with Guzzle:**
+
+```php
+use GuzzleHttp\Client;
+
+$client = new Client([
+ 'base_uri' => 'https://api.example.com/v1/',
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $apiKey,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ ],
+]);
+
+// List posts
+$response = $client->get('posts', [
+ 'query' => [
+ 'status' => 'published',
+ 'per_page' => 50,
+ 'sort' => '-published_at',
+ ],
+]);
+
+$posts = json_decode($response->getBody(), true);
+
+// Create a post
+$response = $client->post('posts', [
+ 'json' => [
+ 'title' => 'New Post',
+ 'content' => 'Post content here...',
+ 'status' => 'draft',
+ ],
+]);
+
+$newPost = json_decode($response->getBody(), true);
+```
+
+**JavaScript with Fetch:**
+
+```javascript
+const API_KEY = 'sk_live_abc123...';
+const BASE_URL = 'https://api.example.com/v1';
+
+async function listPosts(params = {}) {
+ const query = new URLSearchParams(params).toString();
+
+ const response = await fetch(`${BASE_URL}/posts?${query}`, {
+ headers: {
+ 'Authorization': `Bearer ${API_KEY}`,
+ 'Accept': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+async function createPost(data) {
+ const response = await fetch(`${BASE_URL}/posts`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${API_KEY}`,
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || 'Failed to create post');
+ }
+
+ return response.json();
+}
+
+// Usage
+const posts = await listPosts({ status: 'published', per_page: 25 });
+const newPost = await createPost({ title: 'Hello', content: 'World' });
+```
+
+**Python with Requests:**
+
+```python
+import requests
+
+API_KEY = 'sk_live_abc123...'
+BASE_URL = 'https://api.example.com/v1'
+
+headers = {
+ 'Authorization': f'Bearer {API_KEY}',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+}
+
+# List posts
+response = requests.get(
+ f'{BASE_URL}/posts',
+ headers=headers,
+ params={
+ 'status': 'published',
+ 'per_page': 50,
+ 'sort': '-published_at',
+ }
+)
+response.raise_for_status()
+posts = response.json()
+
+# Create a post
+response = requests.post(
+ f'{BASE_URL}/posts',
+ headers=headers,
+ json={
+ 'title': 'New Post',
+ 'content': 'Post content here...',
+ 'status': 'draft',
+ }
+)
+response.raise_for_status()
+new_post = response.json()
+```
+
+## OpenAPI Documentation
+
+### Document Endpoints
+
+Use attributes to auto-generate OpenAPI documentation:
+
+```php
+use Core\Mod\Api\Documentation\Attributes\ApiTag;
+use Core\Mod\Api\Documentation\Attributes\ApiParameter;
+use Core\Mod\Api\Documentation\Attributes\ApiResponse;
+use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
+
+#[ApiTag('Posts', 'Blog post management')]
+#[ApiSecurity('api_key')]
+class PostController extends Controller
+{
+ #[ApiParameter('page', 'query', 'integer', 'Page number', example: 1)]
+ #[ApiParameter('per_page', 'query', 'integer', 'Items per page', example: 25)]
+ #[ApiParameter('status', 'query', 'string', 'Filter by status', enum: ['draft', 'published'])]
+ #[ApiParameter('sort', 'query', 'string', 'Sort fields (prefix with - for desc)', example: '-created_at')]
+ #[ApiResponse(200, PostResource::class, 'List of posts', paginated: true)]
+ public function index(Request $request)
+ {
+ // ...
+ }
+
+ #[ApiParameter('id', 'path', 'integer', 'Post ID', required: true)]
+ #[ApiResponse(200, PostResource::class, 'Post details')]
+ #[ApiResponse(404, null, 'Post not found')]
+ public function show(Post $post)
+ {
+ // ...
+ }
+
+ #[ApiResponse(201, PostResource::class, 'Post created')]
+ #[ApiResponse(422, null, 'Validation error')]
+ public function store(Request $request)
+ {
+ // ...
+ }
+}
+```
+
+## Error Handling
+
+### Consistent Error Responses
+
+Use the `HasApiResponses` trait for consistent errors:
+
+```php
+use Core\Mod\Api\Concerns\HasApiResponses;
+
+class PostController extends Controller
+{
+ use HasApiResponses;
+
+ public function show($id)
+ {
+ $post = Post::find($id);
+
+ if (!$post) {
+ return $this->notFoundResponse('Post');
+ }
+
+ return new PostResource($post);
+ }
+
+ public function store(Request $request)
+ {
+ // Check entitlement limits
+ if (!$this->canCreatePost($request->user())) {
+ return $this->limitReachedResponse(
+ 'posts',
+ 'You have reached your post limit. Please upgrade your plan.'
+ );
+ }
+
+ // Validation errors are handled automatically by Laravel
+ $validated = $request->validate([...]);
+
+ // ...
+ }
+}
+```
+
+### Error Response Format
+
+All errors follow a consistent format:
+
+```json
+{
+ "error": "not_found",
+ "message": "Post not found."
+}
+```
+
+```json
+{
+ "error": "validation_failed",
+ "message": "The given data was invalid.",
+ "errors": {
+ "title": ["The title field is required."],
+ "content": ["The content must be at least 100 characters."]
+ }
+}
+```
+
+```json
+{
+ "error": "feature_limit_reached",
+ "message": "You have reached your post limit.",
+ "feature": "posts",
+ "upgrade_url": "https://example.com/upgrade"
+}
+```
+
+## Best Practices
+
+### 1. Use API Resources
+
+Always transform models through resources:
+
+```php
+// Good - consistent response format
+return new PostResource($post);
+
+// Bad - exposes database schema
+return response()->json($post);
+```
+
+### 2. Validate All Input
+
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([
+ 'title' => 'required|string|max:255',
+ 'content' => 'required|string|min:100',
+ 'status' => 'in:draft,published',
+ 'published_at' => 'nullable|date|after:now',
+ ]);
+
+ // Use validated data only
+ $post = Post::create($validated);
+}
+```
+
+### 3. Eager Load Relationships
+
+```php
+// Good - single query with eager loading
+$posts = Post::with(['author', 'categories'])->paginate();
+
+// Bad - N+1 queries
+$posts = Post::paginate();
+foreach ($posts as $post) {
+ echo $post->author->name; // Additional query per post
+}
+```
+
+### 4. Use Route Model Binding
+
+```php
+// Good - automatic 404 if not found
+public function show(Post $post)
+{
+ return new PostResource($post);
+}
+
+// Unnecessary - route model binding handles this
+public function show($id)
+{
+ $post = Post::findOrFail($id);
+ return new PostResource($post);
+}
+```
+
+### 5. Scope Data by Workspace
+
+```php
+public function index(Request $request)
+{
+ $workspaceId = $request->user()->currentWorkspaceId();
+
+ $posts = Post::where('workspace_id', $workspaceId)
+ ->paginate();
+
+ return new PaginatedCollection($posts, PostResource::class);
+}
+```
+
+## Testing
+
+### Feature Tests
+
+```php
+create([
+ 'scopes' => ['posts:read'],
+ ]);
+
+ Post::factory()->count(5)->create();
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->getJson('/api/v1/posts');
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'data' => [
+ '*' => ['id', 'type', 'attributes'],
+ ],
+ 'meta' => ['current_page', 'total'],
+ 'links',
+ ]);
+ }
+
+ public function test_filters_posts_by_status(): void
+ {
+ $apiKey = ApiKey::factory()->create(['scopes' => ['posts:read']]);
+
+ Post::factory()->create(['status' => 'draft']);
+ Post::factory()->create(['status' => 'published']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->getJson('/api/v1/posts?status=published');
+
+ $response->assertOk()
+ ->assertJsonCount(1, 'data');
+ }
+
+ public function test_creates_post_with_valid_scope(): void
+ {
+ $apiKey = ApiKey::factory()->create([
+ 'scopes' => ['posts:write'],
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->postJson('/api/v1/posts', [
+ 'title' => 'Test Post',
+ 'content' => 'Test content...',
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonPath('data.attributes.title', 'Test Post');
+ }
+
+ public function test_rejects_create_without_scope(): void
+ {
+ $apiKey = ApiKey::factory()->create([
+ 'scopes' => ['posts:read'], // No write scope
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->postJson('/api/v1/posts', [
+ 'title' => 'Test Post',
+ 'content' => 'Test content...',
+ ]);
+
+ $response->assertForbidden();
+ }
+}
+```
+
+## Learn More
+
+- [Authentication](/packages/api/authentication) - API key management
+- [Rate Limiting](/packages/api/rate-limiting) - Tier-based rate limits
+- [Scopes](/packages/api/scopes) - Permission system
+- [Webhooks](/packages/api/webhooks) - Event notifications
+- [OpenAPI Documentation](/packages/api/documentation) - Auto-generated docs
diff --git a/docs/packages/api/documentation.md b/docs/packages/api/documentation.md
new file mode 100644
index 0000000..61bec4c
--- /dev/null
+++ b/docs/packages/api/documentation.md
@@ -0,0 +1,474 @@
+# API Documentation
+
+Automatically generate OpenAPI 3.0 documentation with Swagger UI, Scalar, and ReDoc viewers.
+
+## Overview
+
+The API package automatically generates OpenAPI documentation from your routes, controllers, and doc blocks.
+
+**Features:**
+- Automatic route discovery
+- OpenAPI 3.0 spec generation
+- Multiple documentation viewers
+- Security scheme documentation
+- Request/response examples
+- Interactive API explorer
+
+## Accessing Documentation
+
+### Available Endpoints
+
+```
+/api/docs - Swagger UI (default)
+/api/docs/scalar - Scalar viewer
+/api/docs/redoc - ReDoc viewer
+/api/docs/openapi - Raw OpenAPI JSON
+```
+
+### Protection
+
+Documentation is protected in production:
+
+```php
+// config/api.php
+return [
+ 'documentation' => [
+ 'enabled' => env('API_DOCS_ENABLED', !app()->isProduction()),
+ 'middleware' => ['auth', 'can:view-api-docs'],
+ ],
+];
+```
+
+## Attributes
+
+### Hiding Endpoints
+
+```php
+use Mod\Api\Documentation\Attributes\ApiHidden;
+
+#[ApiHidden]
+class InternalController
+{
+ // Entire controller hidden from docs
+}
+
+class PostController
+{
+ #[ApiHidden]
+ public function internalMethod()
+ {
+ // Single method hidden
+ }
+}
+```
+
+### Tagging Endpoints
+
+```php
+use Mod\Api\Documentation\Attributes\ApiTag;
+
+#[ApiTag('Blog Posts')]
+class PostController
+{
+ // All methods tagged with "Blog Posts"
+}
+```
+
+### Documenting Parameters
+
+```php
+use Mod\Api\Documentation\Attributes\ApiParameter;
+
+class PostController
+{
+ #[ApiParameter(
+ name: 'status',
+ in: 'query',
+ description: 'Filter by post status',
+ required: false,
+ schema: ['type' => 'string', 'enum' => ['draft', 'published', 'archived']]
+ )]
+ #[ApiParameter(
+ name: 'category',
+ in: 'query',
+ description: 'Filter by category ID',
+ schema: ['type' => 'integer']
+ )]
+ public function index(Request $request)
+ {
+ // GET /posts?status=published&category=5
+ }
+}
+```
+
+### Documenting Responses
+
+```php
+use Mod\Api\Documentation\Attributes\ApiResponse;
+
+class PostController
+{
+ #[ApiResponse(
+ status: 200,
+ description: 'Post created successfully',
+ content: [
+ 'application/json' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ 'title' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ ],
+ ],
+ ],
+ ]
+ )]
+ #[ApiResponse(
+ status: 422,
+ description: 'Validation error'
+ )]
+ public function store(Request $request)
+ {
+ // ...
+ }
+}
+```
+
+### Security Requirements
+
+```php
+use Mod\Api\Documentation\Attributes\ApiSecurity;
+
+#[ApiSecurity(['apiKey' => []])]
+class PostController
+{
+ // Requires API key authentication
+}
+
+#[ApiSecurity(['bearerAuth' => ['posts:write']])]
+public function store(Request $request)
+{
+ // Requires Bearer token with posts:write scope
+}
+```
+
+## Configuration
+
+```php
+// config/api.php
+return [
+ 'documentation' => [
+ 'enabled' => true,
+
+ 'info' => [
+ 'title' => 'Core PHP Framework API',
+ 'description' => 'REST API for Core PHP Framework',
+ 'version' => '1.0.0',
+ 'contact' => [
+ 'name' => 'API Support',
+ 'email' => 'api@example.com',
+ 'url' => 'https://example.com/support',
+ ],
+ ],
+
+ 'servers' => [
+ [
+ 'url' => 'https://api.example.com',
+ 'description' => 'Production',
+ ],
+ [
+ 'url' => 'https://staging.example.com',
+ 'description' => 'Staging',
+ ],
+ ],
+
+ 'security_schemes' => [
+ 'apiKey' => [
+ 'type' => 'http',
+ 'scheme' => 'bearer',
+ 'bearerFormat' => 'API Key',
+ 'description' => 'API key authentication. Format: `Bearer sk_live_...`',
+ ],
+ ],
+
+ 'viewers' => [
+ 'swagger' => true,
+ 'scalar' => true,
+ 'redoc' => true,
+ ],
+ ],
+];
+```
+
+## Extensions
+
+### Custom Extensions
+
+```php
+ 'Blog Posts',
+ 'description' => 'Operations for managing blog posts',
+ ];
+
+ // Add custom security requirements
+ $spec['paths']['/posts']['post']['security'][] = [
+ 'apiKey' => [],
+ ];
+
+ return $spec;
+ }
+}
+```
+
+**Register Extension:**
+
+```php
+use Core\Events\ApiRoutesRegistering;
+
+public function onApiRoutes(ApiRoutesRegistering $event): void
+{
+ $event->documentationExtension(new BlogExtension());
+}
+```
+
+### Built-in Extensions
+
+**Rate Limit Extension:**
+
+```php
+use Mod\Api\Documentation\Extensions\RateLimitExtension;
+
+// Automatically documents rate limits in responses
+// Adds X-RateLimit-* headers to all endpoints
+```
+
+**Workspace Header Extension:**
+
+```php
+use Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
+
+// Documents X-Workspace-ID header requirement
+// Adds to all workspace-scoped endpoints
+```
+
+## Common Examples
+
+### Pagination
+
+```php
+use Mod\Api\Documentation\Examples\CommonExamples;
+
+#[ApiResponse(
+ status: 200,
+ description: 'Paginated list of posts',
+ content: CommonExamples::paginatedResponse('posts', [
+ 'id' => 1,
+ 'title' => 'Example Post',
+ 'status' => 'published',
+ ])
+)]
+public function index(Request $request)
+{
+ return PostResource::collection(
+ Post::paginate(20)
+ );
+}
+```
+
+**Generates:**
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "title": "Example Post",
+ "status": "published"
+ }
+ ],
+ "links": {
+ "first": "...",
+ "last": "...",
+ "prev": null,
+ "next": "..."
+ },
+ "meta": {
+ "current_page": 1,
+ "total": 100
+ }
+}
+```
+
+### Error Responses
+
+```php
+#[ApiResponse(
+ status: 404,
+ description: 'Post not found',
+ content: CommonExamples::errorResponse('Post not found', 'resource_not_found')
+)]
+public function show(Post $post)
+{
+ return new PostResource($post);
+}
+```
+
+## Module Discovery
+
+The documentation system automatically discovers API routes from all modules:
+
+```php
+// Mod\Blog\Boot
+public function onApiRoutes(ApiRoutesRegistering $event): void
+{
+ $event->routes(function () {
+ Route::get('/posts', [PostController::class, 'index']);
+ // Automatically included in docs
+ });
+}
+```
+
+**Discovery Process:**
+1. Scan all registered API routes
+2. Extract controller methods
+3. Parse doc blocks and attributes
+4. Generate OpenAPI spec
+5. Cache for performance
+
+## Viewers
+
+### Swagger UI
+
+Interactive API explorer with "Try it out" functionality.
+
+**Access:** `/api/docs`
+
+**Features:**
+- Test endpoints directly
+- View request/response examples
+- OAuth/API key authentication
+- Model schemas
+
+### Scalar
+
+Modern, clean documentation viewer.
+
+**Access:** `/api/docs/scalar`
+
+**Features:**
+- Beautiful UI
+- Dark mode
+- Code examples in multiple languages
+- Interactive examples
+
+### ReDoc
+
+Professional documentation with three-panel layout.
+
+**Access:** `/api/docs/redoc`
+
+**Features:**
+- Search functionality
+- Menu navigation
+- Responsive design
+- Printable
+
+## Best Practices
+
+### 1. Document All Public Endpoints
+
+```php
+// ✅ Good - documented
+#[ApiTag('Posts')]
+#[ApiResponse(200, 'Success')]
+#[ApiResponse(422, 'Validation error')]
+public function store(Request $request)
+
+// ❌ Bad - undocumented
+public function store(Request $request)
+```
+
+### 2. Provide Examples
+
+```php
+// ✅ Good - request example
+#[ApiParameter(
+ name: 'status',
+ example: 'published'
+)]
+
+// ❌ Bad - no example
+#[ApiParameter(name: 'status')]
+```
+
+### 3. Hide Internal Endpoints
+
+```php
+// ✅ Good - hidden
+#[ApiHidden]
+public function internal()
+
+// ❌ Bad - exposed in docs
+public function internal()
+```
+
+### 4. Group Related Endpoints
+
+```php
+// ✅ Good - tagged
+#[ApiTag('Blog Posts')]
+class PostController
+
+// ❌ Bad - ungrouped
+class PostController
+```
+
+## Testing
+
+```php
+use Tests\TestCase;
+
+class DocumentationTest extends TestCase
+{
+ public function test_generates_openapi_spec(): void
+ {
+ $response = $this->getJson('/api/docs/openapi');
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure([
+ 'openapi',
+ 'info',
+ 'paths',
+ 'components',
+ ]);
+ }
+
+ public function test_includes_blog_endpoints(): void
+ {
+ $response = $this->getJson('/api/docs/openapi');
+
+ $spec = $response->json();
+
+ $this->assertArrayHasKey('/posts', $spec['paths']);
+ $this->assertArrayHasKey('/posts/{id}', $spec['paths']);
+ }
+}
+```
+
+## Learn More
+
+- [Authentication →](/packages/api/authentication)
+- [Scopes →](/packages/api/scopes)
+- [API Reference →](/api/endpoints)
diff --git a/docs/packages/api/endpoints-reference.md b/docs/packages/api/endpoints-reference.md
new file mode 100644
index 0000000..8534bd4
--- /dev/null
+++ b/docs/packages/api/endpoints-reference.md
@@ -0,0 +1,1129 @@
+# API Endpoints Reference
+
+Complete reference for all core-api endpoints. All endpoints follow RESTful conventions with consistent authentication, pagination, filtering, and error handling.
+
+## Base URL
+
+```
+https://your-domain.com/api/v1
+```
+
+## Authentication
+
+All authenticated endpoints require an API key in the Authorization header:
+
+```http
+Authorization: Bearer sk_live_abc123def456...
+```
+
+See [Authentication](/packages/api/authentication) for details on creating and managing API keys.
+
+## Common Headers
+
+### Request Headers
+
+| Header | Required | Description |
+|--------|----------|-------------|
+| `Authorization` | Yes* | API key (Bearer token) |
+| `Accept` | No | Should be `application/json` |
+| `Content-Type` | For POST/PUT | Should be `application/json` |
+| `X-Workspace-ID` | Sometimes | Workspace context for multi-tenant endpoints |
+| `Idempotency-Key` | No | UUID for safe retries on POST/PUT/DELETE |
+
+*Required for authenticated endpoints
+
+### Response Headers
+
+| Header | Description |
+|--------|-------------|
+| `X-RateLimit-Limit` | Maximum requests allowed in window |
+| `X-RateLimit-Remaining` | Requests remaining in current window |
+| `X-RateLimit-Reset` | Unix timestamp when limit resets |
+| `X-Request-ID` | Unique request identifier for debugging |
+
+## Common Parameters
+
+### Pagination
+
+All list endpoints support pagination:
+
+| Parameter | Type | Default | Max | Description |
+|-----------|------|---------|-----|-------------|
+| `page` | integer | 1 | - | Page number |
+| `per_page` | integer | 25 | 100 | Items per page |
+
+**Response format:**
+
+```json
+{
+ "data": [...],
+ "meta": {
+ "current_page": 1,
+ "from": 1,
+ "last_page": 10,
+ "per_page": 25,
+ "to": 25,
+ "total": 250
+ },
+ "links": {
+ "first": "https://api.example.com/v1/resource?page=1",
+ "last": "https://api.example.com/v1/resource?page=10",
+ "prev": null,
+ "next": "https://api.example.com/v1/resource?page=2"
+ }
+}
+```
+
+### Filtering
+
+Filter list results with query parameters:
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `status` | string | Filter by status (varies by resource) |
+| `created_after` | ISO 8601 date | Filter by creation date |
+| `created_before` | ISO 8601 date | Filter by creation date |
+| `updated_after` | ISO 8601 date | Filter by update date |
+| `updated_before` | ISO 8601 date | Filter by update date |
+| `search` | string | Full-text search (if supported) |
+
+### Sorting
+
+Sort results using the `sort` parameter:
+
+```http
+GET /api/v1/resources?sort=-created_at,name
+```
+
+- Prefix with `-` for descending order
+- Default is ascending order
+- Comma-separate multiple fields
+
+### Field Selection
+
+Request specific fields only:
+
+```http
+GET /api/v1/resources?fields=id,name,created_at
+```
+
+### Includes
+
+Eager-load related resources:
+
+```http
+GET /api/v1/resources?include=owner,tags
+```
+
+---
+
+## Workspaces
+
+### List Workspaces
+
+```http
+GET /api/v1/workspaces
+```
+
+**Required scope:** `workspaces:read`
+
+**Query parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `page` | integer | Page number |
+| `per_page` | integer | Items per page |
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "name": "Acme Corporation",
+ "slug": "acme-corp",
+ "tier": "business",
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "meta": {...},
+ "links": {...}
+}
+```
+
+### Get Workspace
+
+```http
+GET /api/v1/workspaces/{id}
+```
+
+**Required scope:** `workspaces:read`
+
+**Path parameters:**
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `id` | integer | Yes | Workspace ID |
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": {
+ "id": 1,
+ "name": "Acme Corporation",
+ "slug": "acme-corp",
+ "tier": "business",
+ "settings": {
+ "timezone": "UTC",
+ "locale": "en_GB"
+ },
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-15T10:30:00Z"
+ }
+}
+```
+
+**Error responses:**
+
+| Status | Code | Description |
+|--------|------|-------------|
+| 404 | `not_found` | Workspace not found |
+
+### Create Workspace
+
+```http
+POST /api/v1/workspaces
+```
+
+**Required scope:** `workspaces:write`
+
+**Request body:**
+
+```json
+{
+ "name": "New Workspace",
+ "slug": "new-workspace",
+ "tier": "pro"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | Yes | Workspace name (max 255 chars) |
+| `slug` | string | No | URL-friendly identifier (auto-generated if not provided) |
+| `tier` | string | No | Subscription tier (default: free) |
+
+**Response:** `201 Created`
+
+```json
+{
+ "message": "Workspace created successfully.",
+ "data": {
+ "id": 2,
+ "name": "New Workspace",
+ "slug": "new-workspace",
+ "tier": "pro",
+ "created_at": "2026-01-15T10:30:00Z"
+ }
+}
+```
+
+**Error responses:**
+
+| Status | Code | Description |
+|--------|------|-------------|
+| 422 | `validation_failed` | Invalid input data |
+
+### Update Workspace
+
+```http
+PATCH /api/v1/workspaces/{id}
+```
+
+**Required scope:** `workspaces:write`
+
+**Request body:**
+
+```json
+{
+ "name": "Updated Name",
+ "settings": {
+ "timezone": "Europe/London"
+ }
+}
+```
+
+**Response:** `200 OK`
+
+### Delete Workspace
+
+```http
+DELETE /api/v1/workspaces/{id}
+```
+
+**Required scope:** `workspaces:delete`
+
+**Response:** `204 No Content`
+
+---
+
+## API Keys
+
+### List API Keys
+
+```http
+GET /api/v1/api-keys
+```
+
+**Required scope:** `api-keys:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "name": "Production API Key",
+ "prefix": "sk_live_abc",
+ "scopes": ["posts:read", "posts:write"],
+ "rate_limit_tier": "pro",
+ "last_used_at": "2026-01-15T10:30:00Z",
+ "expires_at": null,
+ "created_at": "2026-01-01T00:00:00Z"
+ }
+ ]
+}
+```
+
+Note: The full API key is never returned after creation.
+
+### Get API Key
+
+```http
+GET /api/v1/api-keys/{id}
+```
+
+**Required scope:** `api-keys:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": {
+ "id": 1,
+ "name": "Production API Key",
+ "prefix": "sk_live_abc",
+ "scopes": ["posts:read", "posts:write"],
+ "rate_limit_tier": "pro",
+ "last_used_at": "2026-01-15T10:30:00Z",
+ "expires_at": null,
+ "created_at": "2026-01-01T00:00:00Z"
+ }
+}
+```
+
+### Create API Key
+
+```http
+POST /api/v1/api-keys
+```
+
+**Required scope:** `api-keys:write`
+
+**Request body:**
+
+```json
+{
+ "name": "Mobile App Key",
+ "scopes": ["posts:read", "users:read"],
+ "rate_limit_tier": "pro",
+ "expires_at": "2027-01-01T00:00:00Z"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | Yes | Key name (max 255 chars) |
+| `scopes` | array | No | Permission scopes (default: read, write) |
+| `rate_limit_tier` | string | No | Rate limit tier (default: from workspace) |
+| `expires_at` | ISO 8601 | No | Expiration date (null = never) |
+
+**Response:** `201 Created`
+
+```json
+{
+ "message": "API key created successfully.",
+ "data": {
+ "id": 2,
+ "name": "Mobile App Key",
+ "key": "sk_live_abc123def456ghi789...",
+ "scopes": ["posts:read", "users:read"],
+ "rate_limit_tier": "pro",
+ "expires_at": "2027-01-01T00:00:00Z",
+ "created_at": "2026-01-15T10:30:00Z"
+ }
+}
+```
+
+**Important:** The `key` field is only returned once during creation. Store it securely.
+
+### Rotate API Key
+
+```http
+POST /api/v1/api-keys/{id}/rotate
+```
+
+**Required scope:** `api-keys:write`
+
+**Request body:**
+
+```json
+{
+ "grace_period_hours": 24
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `grace_period_hours` | integer | No | Hours both keys work (default: 24) |
+
+**Response:** `200 OK`
+
+```json
+{
+ "message": "API key rotated successfully.",
+ "data": {
+ "id": 3,
+ "name": "Mobile App Key",
+ "key": "sk_live_new123key456...",
+ "scopes": ["posts:read", "users:read"],
+ "grace_period_ends_at": "2026-01-16T10:30:00Z"
+ }
+}
+```
+
+### Revoke API Key
+
+```http
+DELETE /api/v1/api-keys/{id}
+```
+
+**Required scope:** `api-keys:delete`
+
+**Response:** `204 No Content`
+
+---
+
+## Webhooks
+
+### List Webhook Endpoints
+
+```http
+GET /api/v1/webhooks
+```
+
+**Required scope:** `webhooks:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "url": "https://your-app.com/webhooks",
+ "events": ["post.created", "post.updated"],
+ "is_active": true,
+ "success_count": 150,
+ "failure_count": 2,
+ "last_delivery_at": "2026-01-15T10:30:00Z",
+ "created_at": "2026-01-01T00:00:00Z"
+ }
+ ]
+}
+```
+
+### Get Webhook Endpoint
+
+```http
+GET /api/v1/webhooks/{id}
+```
+
+**Required scope:** `webhooks:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": {
+ "id": 1,
+ "url": "https://your-app.com/webhooks",
+ "events": ["post.created", "post.updated"],
+ "is_active": true,
+ "success_count": 150,
+ "failure_count": 2,
+ "consecutive_failures": 0,
+ "last_delivery_at": "2026-01-15T10:30:00Z",
+ "created_at": "2026-01-01T00:00:00Z"
+ }
+}
+```
+
+### Create Webhook Endpoint
+
+```http
+POST /api/v1/webhooks
+```
+
+**Required scope:** `webhooks:write`
+
+**Request body:**
+
+```json
+{
+ "url": "https://your-app.com/webhooks",
+ "events": ["post.created", "post.updated", "post.deleted"],
+ "secret": "whsec_abc123def456..."
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `url` | string | Yes | Webhook endpoint URL (HTTPS required) |
+| `events` | array | Yes | Events to subscribe to |
+| `secret` | string | No | Signing secret (auto-generated if not provided) |
+
+**Response:** `201 Created`
+
+```json
+{
+ "message": "Webhook endpoint created successfully.",
+ "data": {
+ "id": 2,
+ "url": "https://your-app.com/webhooks",
+ "events": ["post.created", "post.updated", "post.deleted"],
+ "secret": "whsec_abc123def456...",
+ "is_active": true,
+ "created_at": "2026-01-15T10:30:00Z"
+ }
+}
+```
+
+**Important:** The `secret` is only returned during creation. Store it securely.
+
+### Update Webhook Endpoint
+
+```http
+PATCH /api/v1/webhooks/{id}
+```
+
+**Required scope:** `webhooks:write`
+
+**Request body:**
+
+```json
+{
+ "url": "https://new-url.com/webhooks",
+ "events": ["post.*"],
+ "is_active": true
+}
+```
+
+**Response:** `200 OK`
+
+### Delete Webhook Endpoint
+
+```http
+DELETE /api/v1/webhooks/{id}
+```
+
+**Required scope:** `webhooks:delete`
+
+**Response:** `204 No Content`
+
+### Test Webhook Endpoint
+
+```http
+POST /api/v1/webhooks/{id}/test
+```
+
+**Required scope:** `webhooks:write`
+
+Sends a test event to the webhook endpoint.
+
+**Response:** `200 OK`
+
+```json
+{
+ "success": true,
+ "status_code": 200,
+ "response_time_ms": 145,
+ "response_body": "{\"received\": true}"
+}
+```
+
+**Error response (delivery failed):**
+
+```json
+{
+ "success": false,
+ "status_code": 500,
+ "error": "Connection timeout",
+ "response_time_ms": 30000
+}
+```
+
+### List Webhook Deliveries
+
+```http
+GET /api/v1/webhooks/{id}/deliveries
+```
+
+**Required scope:** `webhooks:read`
+
+**Query parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `status` | string | Filter by status: `pending`, `success`, `failed`, `retrying` |
+| `page` | integer | Page number |
+| `per_page` | integer | Items per page |
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "event_id": "evt_abc123def456",
+ "event_type": "post.created",
+ "status": "success",
+ "response_code": 200,
+ "attempt": 1,
+ "delivered_at": "2026-01-15T10:30:00Z",
+ "created_at": "2026-01-15T10:30:00Z"
+ },
+ {
+ "id": 2,
+ "event_id": "evt_xyz789",
+ "event_type": "post.updated",
+ "status": "retrying",
+ "response_code": 500,
+ "attempt": 2,
+ "next_retry_at": "2026-01-15T10:35:00Z",
+ "created_at": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "meta": {...},
+ "links": {...}
+}
+```
+
+### Retry Webhook Delivery
+
+```http
+POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry
+```
+
+**Required scope:** `webhooks:write`
+
+Manually retry a failed delivery.
+
+**Response:** `200 OK`
+
+```json
+{
+ "message": "Delivery queued for retry.",
+ "data": {
+ "id": 2,
+ "status": "pending",
+ "attempt": 3
+ }
+}
+```
+
+**Error responses:**
+
+| Status | Code | Description |
+|--------|------|-------------|
+| 400 | `cannot_retry` | Delivery already succeeded or max retries reached |
+
+---
+
+## Entitlements
+
+### Check Feature Access
+
+```http
+GET /api/v1/entitlements/check
+```
+
+**Required scope:** `entitlements:read`
+
+**Query parameters:**
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `feature` | string | Yes | Feature key to check |
+| `quantity` | integer | No | Amount to check (default: 1) |
+
+**Response:** `200 OK`
+
+```json
+{
+ "allowed": true,
+ "feature": "posts",
+ "current_usage": 45,
+ "limit": 100,
+ "available": 55
+}
+```
+
+**Response (limit exceeded):**
+
+```json
+{
+ "allowed": false,
+ "feature": "posts",
+ "reason": "LIMIT_EXCEEDED",
+ "message": "Post limit exceeded. Used: 100, Limit: 100",
+ "current_usage": 100,
+ "limit": 100,
+ "available": 0,
+ "upgrade_url": "https://example.com/upgrade"
+}
+```
+
+### Record Usage
+
+```http
+POST /api/v1/entitlements/usage
+```
+
+**Required scope:** `entitlements:write`
+
+**Request body:**
+
+```json
+{
+ "feature": "api_calls",
+ "quantity": 1,
+ "metadata": {
+ "endpoint": "/api/v1/posts"
+ }
+}
+```
+
+**Response:** `200 OK`
+
+```json
+{
+ "recorded": true,
+ "feature": "api_calls",
+ "current_usage": 5001,
+ "limit": 10000
+}
+```
+
+### Get Usage Summary
+
+```http
+GET /api/v1/entitlements/summary
+```
+
+**Required scope:** `entitlements:read`
+
+Returns usage summary for the authenticated user's workspace.
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": {
+ "workspace_id": 1,
+ "tier": "pro",
+ "entitlements": {
+ "posts": {
+ "used": 45,
+ "limit": 1000,
+ "available": 955,
+ "percentage": 4.5
+ },
+ "api_calls": {
+ "used": 5001,
+ "limit": 10000,
+ "available": 4999,
+ "percentage": 50.01,
+ "reset_at": "2026-02-01T00:00:00Z"
+ },
+ "storage": {
+ "used": 1073741824,
+ "limit": 5368709120,
+ "available": 4294967296,
+ "percentage": 20,
+ "unit": "bytes"
+ }
+ }
+ }
+}
+```
+
+---
+
+## SEO Reports
+
+### Submit SEO Report
+
+```http
+POST /api/v1/seo/report
+```
+
+**Required scope:** `seo:write`
+
+**Request body:**
+
+```json
+{
+ "url": "https://example.com/page",
+ "scores": {
+ "performance": 85,
+ "accessibility": 92,
+ "best_practices": 88,
+ "seo": 95
+ },
+ "issues": [
+ {
+ "type": "missing_alt",
+ "severity": "warning",
+ "element": "img.hero-image"
+ }
+ ]
+}
+```
+
+**Response:** `201 Created`
+
+### Get SEO Issues
+
+```http
+GET /api/v1/seo/issues/{workspace_id}
+```
+
+**Required scope:** `seo:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": 1,
+ "url": "https://example.com/page",
+ "issue_type": "missing_alt",
+ "severity": "warning",
+ "details": {...},
+ "created_at": "2026-01-15T10:30:00Z"
+ }
+ ]
+}
+```
+
+---
+
+## Pixel Tracking
+
+### Get Pixel Configuration
+
+```http
+GET /api/v1/pixel/config
+```
+
+**Authentication:** Not required
+
+Returns tracking pixel configuration for the current domain.
+
+**Response:** `200 OK`
+
+```json
+{
+ "enabled": true,
+ "features": {
+ "pageviews": true,
+ "events": true,
+ "sessions": true
+ },
+ "sample_rate": 1.0
+}
+```
+
+### Track Event
+
+```http
+POST /api/v1/pixel/track
+```
+
+**Authentication:** Not required
+
+**Rate limit:** 300 requests per minute
+
+**Request body:**
+
+```json
+{
+ "event": "pageview",
+ "url": "https://example.com/page",
+ "referrer": "https://google.com",
+ "user_agent": "Mozilla/5.0...",
+ "properties": {
+ "title": "Page Title"
+ }
+}
+```
+
+**Response:** `200 OK`
+
+```json
+{
+ "tracked": true
+}
+```
+
+---
+
+## MCP (Model Context Protocol)
+
+### List MCP Servers
+
+```http
+GET /api/v1/mcp/servers
+```
+
+**Required scope:** `mcp:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "id": "filesystem",
+ "name": "Filesystem Server",
+ "description": "File and directory operations",
+ "tools": ["read_file", "write_file", "list_directory"]
+ }
+ ]
+}
+```
+
+### Get MCP Server
+
+```http
+GET /api/v1/mcp/servers/{id}
+```
+
+**Required scope:** `mcp:read`
+
+### List Server Tools
+
+```http
+GET /api/v1/mcp/servers/{id}/tools
+```
+
+**Required scope:** `mcp:read`
+
+**Response:** `200 OK`
+
+```json
+{
+ "data": [
+ {
+ "name": "read_file",
+ "description": "Read contents of a file",
+ "parameters": {
+ "path": {
+ "type": "string",
+ "description": "File path to read",
+ "required": true
+ }
+ }
+ }
+ ]
+}
+```
+
+### Call MCP Tool
+
+```http
+POST /api/v1/mcp/tools/call
+```
+
+**Required scope:** `mcp:write`
+
+**Request body:**
+
+```json
+{
+ "server": "filesystem",
+ "tool": "read_file",
+ "arguments": {
+ "path": "/path/to/file.txt"
+ }
+}
+```
+
+**Response:** `200 OK`
+
+```json
+{
+ "result": {
+ "content": "File contents here...",
+ "size": 1234
+ }
+}
+```
+
+### Get MCP Resource
+
+```http
+GET /api/v1/mcp/resources/{uri}
+```
+
+**Required scope:** `mcp:read`
+
+The `uri` can include slashes and will be URL-decoded.
+
+---
+
+## Error Responses
+
+All errors follow a consistent format:
+
+### Validation Error (422)
+
+```json
+{
+ "error": "validation_failed",
+ "message": "The given data was invalid.",
+ "errors": {
+ "name": ["The name field is required."],
+ "email": ["The email must be a valid email address."]
+ }
+}
+```
+
+### Not Found (404)
+
+```json
+{
+ "error": "not_found",
+ "message": "Resource not found."
+}
+```
+
+### Unauthorized (401)
+
+```json
+{
+ "error": "unauthorized",
+ "message": "Invalid or missing API key."
+}
+```
+
+### Forbidden (403)
+
+```json
+{
+ "error": "access_denied",
+ "message": "Insufficient permissions. Required scope: posts:write"
+}
+```
+
+### Feature Limit Reached (403)
+
+```json
+{
+ "error": "feature_limit_reached",
+ "message": "You have reached your limit for this feature.",
+ "feature": "posts",
+ "upgrade_url": "https://example.com/upgrade"
+}
+```
+
+### Rate Limited (429)
+
+```json
+{
+ "error": "rate_limit_exceeded",
+ "message": "Too many requests. Please retry after 60 seconds.",
+ "retry_after": 60,
+ "limit": 1000,
+ "remaining": 0,
+ "reset_at": "2026-01-15T11:00:00Z"
+}
+```
+
+### Server Error (500)
+
+```json
+{
+ "error": "server_error",
+ "message": "An unexpected error occurred.",
+ "request_id": "req_abc123def456"
+}
+```
+
+---
+
+## Rate Limits
+
+Rate limits vary by tier:
+
+| Tier | Requests/Minute | Burst Allowance |
+|------|-----------------|-----------------|
+| Free | 60 | None |
+| Starter | 1,000 | 20% |
+| Pro | 5,000 | 30% |
+| Agency | 20,000 | 50% |
+| Enterprise | 100,000 | 100% |
+
+Rate limit headers are included in every response:
+
+```http
+X-RateLimit-Limit: 5000
+X-RateLimit-Remaining: 4892
+X-RateLimit-Reset: 1705312260
+```
+
+See [Rate Limiting](/packages/api/rate-limiting) for details.
+
+---
+
+## Idempotency
+
+For safe retries on POST, PUT, and DELETE requests, include an idempotency key:
+
+```http
+POST /api/v1/posts
+Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
+```
+
+If the same idempotency key is used within 24 hours:
+- Same status code and response body returned
+- No duplicate resource created
+- Safe to retry failed requests
+
+---
+
+## Learn More
+
+- [Building REST APIs](/packages/api/building-rest-apis) - Tutorial for creating API endpoints
+- [Authentication](/packages/api/authentication) - API key management
+- [Webhooks](/packages/api/webhooks) - Event notifications
+- [Webhook Integration](/packages/api/webhook-integration) - Consumer guide
+- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits
+- [Scopes](/packages/api/scopes) - Permission system
diff --git a/docs/packages/api/index.md b/docs/packages/api/index.md
new file mode 100644
index 0000000..03ff405
--- /dev/null
+++ b/docs/packages/api/index.md
@@ -0,0 +1,338 @@
+# API Package
+
+The API package provides a complete REST API with secure authentication, rate limiting, webhooks, and OpenAPI documentation.
+
+## Installation
+
+```bash
+composer require host-uk/core-api
+```
+
+## Quick Start
+
+```php
+ 'onApiRoutes',
+ ];
+
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ $event->routes(function () {
+ Route::get('/posts', [Api\PostController::class, 'index']);
+ Route::post('/posts', [Api\PostController::class, 'store']);
+ Route::get('/posts/{id}', [Api\PostController::class, 'show']);
+ });
+ }
+}
+```
+
+## Key Features
+
+### Authentication & Security
+
+- **[API Keys](/packages/api/authentication)** - Secure API key management with bcrypt hashing
+- **[Scopes](/packages/api/scopes)** - Fine-grained permission system
+- **[Rate Limiting](/packages/api/rate-limiting)** - Tier-based rate limits with Redis backend
+- **[Key Rotation](/packages/api/authentication#rotation)** - Secure key rotation with grace periods
+
+### Webhooks
+
+- **[Webhook Endpoints](/packages/api/webhooks)** - Event-driven notifications
+- **[Signatures](/packages/api/webhooks#signatures)** - HMAC-SHA256 signature verification
+- **[Delivery Tracking](/packages/api/webhooks#delivery)** - Retry logic and delivery history
+
+### Documentation
+
+- **[OpenAPI Spec](/packages/api/openapi)** - Auto-generated OpenAPI 3.0 documentation
+- **[Interactive Docs](/packages/api/documentation)** - Swagger UI, Scalar, and ReDoc interfaces
+- **[Code Examples](/packages/api/documentation#examples)** - Multi-language code snippets
+
+### Monitoring
+
+- **[Usage Analytics](/packages/api/analytics)** - Track API usage and quota
+- **[Usage Alerts](/packages/api/alerts)** - Automated high-usage notifications
+- **[Request Logging](/packages/api/logging)** - Comprehensive request/response logging
+
+## Authentication
+
+### Creating API Keys
+
+```php
+use Mod\Api\Models\ApiKey;
+
+$apiKey = ApiKey::create([
+ 'name' => 'Mobile App',
+ 'workspace_id' => $workspace->id,
+ 'scopes' => ['posts:read', 'posts:write'],
+ 'rate_limit_tier' => 'pro',
+]);
+
+// Get plaintext key (only shown once!)
+$plaintext = $apiKey->plaintext_key; // sk_live_abc123...
+```
+
+### Using API Keys
+
+```bash
+curl -H "Authorization: Bearer sk_live_abc123..." \
+ https://api.example.com/v1/posts
+```
+
+[Learn more about Authentication →](/packages/api/authentication)
+
+## Rate Limiting
+
+Tier-based rate limits with automatic enforcement:
+
+```php
+// config/api.php
+'rate_limits' => [
+ 'free' => ['requests' => 1000, 'per' => 'hour'],
+ 'pro' => ['requests' => 10000, 'per' => 'hour'],
+ 'business' => ['requests' => 50000, 'per' => 'hour'],
+ 'enterprise' => ['requests' => null], // Unlimited
+],
+```
+
+Rate limit headers included in every response:
+
+```
+X-RateLimit-Limit: 10000
+X-RateLimit-Remaining: 9847
+X-RateLimit-Reset: 1640995200
+```
+
+[Learn more about Rate Limiting →](/packages/api/rate-limiting)
+
+## Webhooks
+
+### Creating Webhooks
+
+```php
+use Mod\Api\Models\WebhookEndpoint;
+
+$webhook = WebhookEndpoint::create([
+ 'url' => 'https://your-app.com/webhooks',
+ 'events' => ['post.created', 'post.updated'],
+ 'secret' => 'whsec_abc123...',
+ 'workspace_id' => $workspace->id,
+]);
+```
+
+### Dispatching Events
+
+```php
+use Mod\Api\Services\WebhookService;
+
+$service = app(WebhookService::class);
+
+$service->dispatch('post.created', [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ 'url' => route('posts.show', $post),
+]);
+```
+
+### Verifying Signatures
+
+```php
+use Mod\Api\Services\WebhookSignature;
+
+$signature = WebhookSignature::verify(
+ payload: $request->getContent(),
+ signature: $request->header('X-Webhook-Signature'),
+ secret: $webhook->secret
+);
+
+if (!$signature) {
+ abort(401, 'Invalid signature');
+}
+```
+
+[Learn more about Webhooks →](/packages/api/webhooks)
+
+## OpenAPI Documentation
+
+Auto-generate OpenAPI documentation with attributes:
+
+```php
+use Mod\Api\Documentation\Attributes\ApiTag;
+use Mod\Api\Documentation\Attributes\ApiParameter;
+use Mod\Api\Documentation\Attributes\ApiResponse;
+
+#[ApiTag('Posts')]
+class PostController extends Controller
+{
+ #[ApiParameter(name: 'page', in: 'query', type: 'integer')]
+ #[ApiParameter(name: 'per_page', in: 'query', type: 'integer')]
+ #[ApiResponse(status: 200, description: 'List of posts')]
+ public function index(Request $request)
+ {
+ return PostResource::collection(
+ Post::paginate($request->input('per_page', 15))
+ );
+ }
+}
+```
+
+View documentation at:
+- `/api/docs` - Swagger UI
+- `/api/docs/scalar` - Scalar interface
+- `/api/docs/redoc` - ReDoc interface
+
+[Learn more about Documentation →](/packages/api/documentation)
+
+## API Resources
+
+Transform models to JSON:
+
+```php
+ $this->id,
+ 'title' => $this->title,
+ 'slug' => $this->slug,
+ 'excerpt' => $this->excerpt,
+ 'content' => $this->when(
+ $request->user()->tokenCan('posts:read-content'),
+ $this->content
+ ),
+ 'status' => $this->status,
+ 'published_at' => $this->published_at?->toIso8601String(),
+ 'created_at' => $this->created_at->toIso8601String(),
+ 'updated_at' => $this->updated_at->toIso8601String(),
+ ];
+ }
+}
+```
+
+## Configuration
+
+```php
+// config/api.php
+return [
+ 'prefix' => 'api/v1',
+ 'middleware' => ['api'],
+
+ 'rate_limits' => [
+ 'free' => ['requests' => 1000, 'per' => 'hour'],
+ 'pro' => ['requests' => 10000, 'per' => 'hour'],
+ 'business' => ['requests' => 50000, 'per' => 'hour'],
+ 'enterprise' => ['requests' => null],
+ ],
+
+ 'api_keys' => [
+ 'hash_algo' => 'bcrypt',
+ 'prefix' => 'sk',
+ 'length' => 32,
+ ],
+
+ 'webhooks' => [
+ 'max_retries' => 3,
+ 'retry_delay' => 60, // seconds
+ 'signature_algo' => 'sha256',
+ ],
+
+ 'documentation' => [
+ 'enabled' => true,
+ 'middleware' => ['web', 'auth'],
+ 'title' => 'API Documentation',
+ ],
+];
+```
+
+## Best Practices
+
+### 1. Use API Resources
+
+```php
+// ✅ Good - API resource
+return PostResource::collection($posts);
+
+// ❌ Bad - raw model data
+return $posts->toArray();
+```
+
+### 2. Implement Scopes
+
+```php
+// ✅ Good - scope protection
+Route::middleware('scope:posts:write')
+ ->post('/posts', [PostController::class, 'store']);
+```
+
+### 3. Verify Webhook Signatures
+
+```php
+// ✅ Good - verify signature
+if (!WebhookSignature::verify($payload, $signature, $secret)) {
+ abort(401);
+}
+```
+
+### 4. Use Rate Limit Middleware
+
+```php
+// ✅ Good - rate limited
+Route::middleware('api.rate-limit')
+ ->group(function () {
+ // API routes
+ });
+```
+
+## Testing
+
+```php
+create([
+ 'scopes' => ['posts:read'],
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => "Bearer {$apiKey->plaintext_key}",
+ ])->getJson('/api/v1/posts');
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'data' => [
+ '*' => ['id', 'title', 'slug'],
+ ],
+ ]);
+ }
+}
+```
+
+## Learn More
+
+- [Authentication →](/packages/api/authentication)
+- [Rate Limiting →](/packages/api/rate-limiting)
+- [Webhooks →](/packages/api/webhooks)
+- [OpenAPI Docs →](/packages/api/documentation)
+- [API Reference →](/api/endpoints)
diff --git a/docs/packages/api/rate-limiting.md b/docs/packages/api/rate-limiting.md
new file mode 100644
index 0000000..4e28438
--- /dev/null
+++ b/docs/packages/api/rate-limiting.md
@@ -0,0 +1,246 @@
+# Rate Limiting
+
+The API package provides tier-based rate limiting with Redis backend, custom limits per endpoint, and automatic enforcement.
+
+## Overview
+
+Rate limiting:
+- Prevents API abuse
+- Ensures fair usage
+- Protects server resources
+- Enforces tier limits
+
+## Tier-Based Limits
+
+Configure limits per tier:
+
+```php
+// config/api.php
+'rate_limits' => [
+ 'free' => [
+ 'requests' => 1000,
+ 'per' => 'hour',
+ ],
+ 'pro' => [
+ 'requests' => 10000,
+ 'per' => 'hour',
+ ],
+ 'business' => [
+ 'requests' => 50000,
+ 'per' => 'hour',
+ ],
+ 'enterprise' => [
+ 'requests' => null, // Unlimited
+ ],
+],
+```
+
+## Response Headers
+
+Every response includes rate limit headers:
+
+```
+X-RateLimit-Limit: 10000
+X-RateLimit-Remaining: 9847
+X-RateLimit-Reset: 1640995200
+```
+
+## Applying Rate Limits
+
+### Global Rate Limiting
+
+```php
+// Apply to all API routes
+Route::middleware('api.rate-limit')->group(function () {
+ Route::get('/posts', [PostController::class, 'index']);
+ Route::post('/posts', [PostController::class, 'store']);
+});
+```
+
+### Per-Endpoint Limits
+
+```php
+// Custom limit for specific endpoint
+Route::get('/search', [SearchController::class, 'index'])
+ ->middleware('throttle:60,1'); // 60 per minute
+```
+
+### Named Rate Limiters
+
+```php
+// app/Providers/RouteServiceProvider.php
+use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Support\Facades\RateLimiter;
+
+RateLimiter::for('api', function (Request $request) {
+ return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
+});
+
+// Apply in routes
+Route::middleware('throttle:api')->group(function () {
+ // Routes
+});
+```
+
+## Custom Rate Limiting
+
+### Based on API Key Tier
+
+```php
+use Mod\Api\Services\RateLimitService;
+
+$rateLimitService = app(RateLimitService::class);
+
+$result = $rateLimitService->attempt($apiKey);
+
+if ($result->exceeded()) {
+ return response()->json([
+ 'error' => 'Rate limit exceeded',
+ 'retry_after' => $result->retryAfter(),
+ ], 429);
+}
+```
+
+### Dynamic Limits
+
+```php
+RateLimiter::for('api', function (Request $request) {
+ $apiKey = $request->user()->currentApiKey();
+
+ return match ($apiKey->rate_limit_tier) {
+ 'free' => Limit::perHour(1000),
+ 'pro' => Limit::perHour(10000),
+ 'business' => Limit::perHour(50000),
+ 'enterprise' => Limit::none(),
+ };
+});
+```
+
+## Rate Limit Responses
+
+### 429 Too Many Requests
+
+```json
+{
+ "message": "Too many requests",
+ "error_code": "RATE_LIMIT_EXCEEDED",
+ "retry_after": 3600,
+ "limit": 10000,
+ "remaining": 0,
+ "reset_at": "2024-01-15T12:00:00Z"
+}
+```
+
+### Retry-After Header
+
+```
+HTTP/1.1 429 Too Many Requests
+Retry-After: 3600
+X-RateLimit-Limit: 10000
+X-RateLimit-Remaining: 0
+X-RateLimit-Reset: 1640995200
+```
+
+## Monitoring
+
+### Check Current Usage
+
+```php
+use Mod\Api\Services\RateLimitService;
+
+$service = app(RateLimitService::class);
+
+$usage = $service->getCurrentUsage($apiKey);
+
+echo "Used: {$usage->used} / {$usage->limit}";
+echo "Remaining: {$usage->remaining}";
+echo "Resets at: {$usage->reset_at}";
+```
+
+### Usage Analytics
+
+```php
+$apiKey = ApiKey::find($id);
+
+$stats = $apiKey->usage()
+ ->whereBetween('created_at', [now()->subDays(7), now()])
+ ->selectRaw('DATE(created_at) as date, COUNT(*) as count')
+ ->groupBy('date')
+ ->get();
+```
+
+## Best Practices
+
+### 1. Handle 429 Gracefully
+
+```javascript
+// ✅ Good - retry with backoff
+async function apiRequest(url, retries = 3) {
+ for (let i = 0; i < retries; i++) {
+ const response = await fetch(url);
+
+ if (response.status === 429) {
+ const retryAfter = parseInt(response.headers.get('Retry-After'));
+ await sleep(retryAfter * 1000);
+ continue;
+ }
+
+ return response;
+ }
+}
+```
+
+### 2. Respect Rate Limit Headers
+
+```javascript
+// ✅ Good - check remaining requests
+const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
+
+if (remaining < 10) {
+ console.warn('Approaching rate limit');
+}
+```
+
+### 3. Implement Exponential Backoff
+
+```javascript
+// ✅ Good - exponential backoff
+async function fetchWithBackoff(url, maxRetries = 5) {
+ for (let i = 0; i < maxRetries; i++) {
+ const response = await fetch(url);
+
+ if (response.status !== 429) {
+ return response;
+ }
+
+ const delay = Math.min(1000 * Math.pow(2, i), 30000);
+ await sleep(delay);
+ }
+}
+```
+
+### 4. Use Caching
+
+```javascript
+// ✅ Good - cache responses
+const cache = new Map();
+
+async function fetchPost(id) {
+ const cached = cache.get(id);
+ if (cached && Date.now() - cached.timestamp < 60000) {
+ return cached.data;
+ }
+
+ const response = await fetch(`/api/v1/posts/${id}`);
+ const data = await response.json();
+
+ cache.set(id, {data, timestamp: Date.now()});
+ return data;
+}
+```
+
+## Learn More
+
+- [API Authentication →](/packages/api/authentication)
+- [Error Handling →](/api/errors)
+- [API Reference →](/api/endpoints#rate-limiting)
diff --git a/docs/packages/api/scopes.md b/docs/packages/api/scopes.md
new file mode 100644
index 0000000..c9a272c
--- /dev/null
+++ b/docs/packages/api/scopes.md
@@ -0,0 +1,548 @@
+# API Scopes
+
+Fine-grained permission control for API keys using OAuth-style scopes.
+
+## Scope Format
+
+Scopes follow the format: `resource:action`
+
+**Examples:**
+- `posts:read` - Read blog posts
+- `posts:write` - Create and update posts
+- `posts:delete` - Delete posts
+- `users:*` - All user operations
+- `*:read` - Read access to all resources
+- `*` - Full access (use sparingly!)
+
+## Available Scopes
+
+### Content Management
+
+| Scope | Description |
+|-------|-------------|
+| `posts:read` | View published posts |
+| `posts:write` | Create and update posts |
+| `posts:delete` | Delete posts |
+| `posts:publish` | Publish posts |
+| `pages:read` | View static pages |
+| `pages:write` | Create and update pages |
+| `pages:delete` | Delete pages |
+| `categories:read` | View categories |
+| `categories:write` | Manage categories |
+| `tags:read` | View tags |
+| `tags:write` | Manage tags |
+
+### User Management
+
+| Scope | Description |
+|-------|-------------|
+| `users:read` | View user profiles |
+| `users:write` | Update user profiles |
+| `users:delete` | Delete users |
+| `users:roles` | Manage user roles |
+| `users:permissions` | Manage user permissions |
+
+### Analytics
+
+| Scope | Description |
+|-------|-------------|
+| `analytics:read` | View analytics data |
+| `analytics:export` | Export analytics |
+| `metrics:read` | View system metrics |
+
+### Webhooks
+
+| Scope | Description |
+|-------|-------------|
+| `webhooks:read` | View webhook endpoints |
+| `webhooks:write` | Create and update webhooks |
+| `webhooks:delete` | Delete webhooks |
+| `webhooks:manage` | Full webhook management |
+
+### API Keys
+
+| Scope | Description |
+|-------|-------------|
+| `keys:read` | View API keys |
+| `keys:write` | Create API keys |
+| `keys:delete` | Delete API keys |
+| `keys:manage` | Full key management |
+
+### Workspace Management
+
+| Scope | Description |
+|-------|-------------|
+| `workspace:read` | View workspace details |
+| `workspace:write` | Update workspace settings |
+| `workspace:members` | Manage workspace members |
+| `workspace:billing` | Access billing information |
+
+### Admin Operations
+
+| Scope | Description |
+|-------|-------------|
+| `admin:users` | Admin user management |
+| `admin:workspaces` | Admin workspace management |
+| `admin:system` | System administration |
+| `admin:*` | Full admin access |
+
+## Assigning Scopes
+
+### API Key Creation
+
+```php
+use Mod\Api\Models\ApiKey;
+
+$apiKey = ApiKey::create([
+ 'name' => 'Mobile App',
+ 'workspace_id' => $workspace->id,
+ 'scopes' => [
+ 'posts:read',
+ 'posts:write',
+ 'categories:read',
+ ],
+]);
+```
+
+### Sanctum Tokens
+
+```php
+$user = User::find(1);
+
+$token = $user->createToken('mobile-app', [
+ 'posts:read',
+ 'posts:write',
+ 'analytics:read',
+])->plainTextToken;
+```
+
+## Scope Enforcement
+
+### Route Protection
+
+```php
+use Mod\Api\Middleware\EnforceApiScope;
+
+// Single scope
+Route::middleware(['auth:sanctum', 'scope:posts:write'])
+ ->post('/posts', [PostController::class, 'store']);
+
+// Multiple scopes (all required)
+Route::middleware(['auth:sanctum', 'scopes:posts:write,categories:read'])
+ ->post('/posts', [PostController::class, 'store']);
+
+// Any scope (at least one required)
+Route::middleware(['auth:sanctum', 'scope-any:posts:write,pages:write'])
+ ->post('/content', [ContentController::class, 'store']);
+```
+
+### Controller Checks
+
+```php
+user()->tokenCan('posts:write')) {
+ abort(403, 'Insufficient permissions');
+ }
+
+ // Check multiple scopes
+ if (!$request->user()->tokenCan('posts:write') ||
+ !$request->user()->tokenCan('categories:read')) {
+ abort(403);
+ }
+
+ // Proceed with creation
+ $post = Post::create($request->validated());
+
+ return new PostResource($post);
+ }
+
+ public function publish(Post $post)
+ {
+ // Require specific scope for sensitive action
+ if (!request()->user()->tokenCan('posts:publish')) {
+ abort(403, 'Publishing requires posts:publish scope');
+ }
+
+ $post->publish();
+
+ return new PostResource($post);
+ }
+}
+```
+
+## Wildcard Scopes
+
+### Resource Wildcards
+
+Grant all permissions for a resource:
+
+```php
+$apiKey->scopes = [
+ 'posts:*', // All post operations
+ 'categories:*', // All category operations
+];
+```
+
+**Equivalent to:**
+
+```php
+$apiKey->scopes = [
+ 'posts:read',
+ 'posts:write',
+ 'posts:delete',
+ 'posts:publish',
+ 'categories:read',
+ 'categories:write',
+ 'categories:delete',
+];
+```
+
+### Action Wildcards
+
+Grant read-only access to everything:
+
+```php
+$apiKey->scopes = [
+ '*:read', // Read access to all resources
+];
+```
+
+### Full Access
+
+```php
+$apiKey->scopes = ['*']; // Full access (dangerous!)
+```
+
+::: warning
+Only use `*` scope for admin integrations. Always prefer specific scopes.
+:::
+
+## Scope Validation
+
+### Custom Scopes
+
+Define custom scopes for your modules:
+
+```php
+ 'View products',
+ 'products:write' => 'Create and update products',
+ 'products:delete' => 'Delete products',
+ 'orders:read' => 'View orders',
+ 'orders:write' => 'Process orders',
+ 'orders:refund' => 'Issue refunds',
+ ];
+ }
+}
+```
+
+**Register Provider:**
+
+```php
+use Core\Events\ApiRoutesRegistering;
+use Mod\Shop\Api\ShopScopeProvider;
+
+public function onApiRoutes(ApiRoutesRegistering $event): void
+{
+ $event->scopes(new ShopScopeProvider());
+}
+```
+
+### Scope Groups
+
+Group related scopes:
+
+```php
+// config/api.php
+return [
+ 'scope_groups' => [
+ 'content_admin' => [
+ 'posts:*',
+ 'pages:*',
+ 'categories:*',
+ 'tags:*',
+ ],
+ 'analytics_viewer' => [
+ 'analytics:read',
+ 'metrics:read',
+ ],
+ 'webhook_manager' => [
+ 'webhooks:*',
+ ],
+ ],
+];
+```
+
+**Usage:**
+
+```php
+// Assign group instead of individual scopes
+$apiKey->scopes = config('api.scope_groups.content_admin');
+```
+
+## Checking Scopes
+
+### Token Abilities
+
+```php
+// Check if token has scope
+if ($request->user()->tokenCan('posts:write')) {
+ // Has permission
+}
+
+// Check multiple scopes (all required)
+if ($request->user()->tokenCan('posts:write') &&
+ $request->user()->tokenCan('posts:publish')) {
+ // Has both permissions
+}
+
+// Get all token abilities
+$abilities = $request->user()->currentAccessToken()->abilities;
+```
+
+### Scope Middleware
+
+```php
+// Require single scope
+Route::middleware('scope:posts:write')->post('/posts', ...);
+
+// Require all scopes
+Route::middleware('scopes:posts:write,categories:read')->post('/posts', ...);
+
+// Require any scope (OR logic)
+Route::middleware('scope-any:posts:write,pages:write')->post('/content', ...);
+```
+
+### API Key Scopes
+
+```php
+use Mod\Api\Models\ApiKey;
+
+$apiKey = ApiKey::findByKey($providedKey);
+
+// Check scope
+if ($apiKey->hasScope('posts:write')) {
+ // Has permission
+}
+
+// Check multiple scopes
+if ($apiKey->hasAllScopes(['posts:write', 'categories:read'])) {
+ // Has all permissions
+}
+
+// Check any scope
+if ($apiKey->hasAnyScope(['posts:write', 'pages:write'])) {
+ // Has at least one permission
+}
+```
+
+## Scope Inheritance
+
+### Hierarchical Scopes
+
+Higher-level scopes include lower-level scopes:
+
+```
+admin:* includes:
+ ├─ admin:users
+ ├─ admin:workspaces
+ └─ admin:system
+
+workspace:* includes:
+ ├─ workspace:read
+ ├─ workspace:write
+ ├─ workspace:members
+ └─ workspace:billing
+```
+
+**Implementation:**
+
+```php
+public function hasScope(string $scope): bool
+{
+ // Exact match
+ if (in_array($scope, $this->scopes)) {
+ return true;
+ }
+
+ // Check wildcards
+ [$resource, $action] = explode(':', $scope);
+
+ // Resource wildcard (e.g., posts:*)
+ if (in_array("{$resource}:*", $this->scopes)) {
+ return true;
+ }
+
+ // Action wildcard (e.g., *:read)
+ if (in_array("*:{$action}", $this->scopes)) {
+ return true;
+ }
+
+ // Full wildcard
+ return in_array('*', $this->scopes);
+}
+```
+
+## Error Responses
+
+### Insufficient Scope
+
+```json
+{
+ "message": "Insufficient scope",
+ "required_scope": "posts:write",
+ "provided_scopes": ["posts:read"],
+ "error_code": "insufficient_scope"
+}
+```
+
+**HTTP Status:** 403 Forbidden
+
+### Missing Scope
+
+```json
+{
+ "message": "This action requires the 'posts:publish' scope",
+ "required_scope": "posts:publish",
+ "error_code": "scope_required"
+}
+```
+
+## Best Practices
+
+### 1. Principle of Least Privilege
+
+```php
+// ✅ Good - minimal scopes
+$apiKey->scopes = [
+ 'posts:read',
+ 'categories:read',
+];
+
+// ❌ Bad - excessive permissions
+$apiKey->scopes = ['*'];
+```
+
+### 2. Use Specific Scopes
+
+```php
+// ✅ Good - specific actions
+$apiKey->scopes = [
+ 'posts:read',
+ 'posts:write',
+];
+
+// ❌ Bad - overly broad
+$apiKey->scopes = ['posts:*'];
+```
+
+### 3. Document Required Scopes
+
+```php
+/**
+ * Publish a blog post.
+ *
+ * Required scopes:
+ * - posts:write (to modify post)
+ * - posts:publish (to change status)
+ *
+ * @requires posts:write
+ * @requires posts:publish
+ */
+public function publish(Post $post)
+{
+ // ...
+}
+```
+
+### 4. Validate Early
+
+```php
+// ✅ Good - check at route level
+Route::middleware('scope:posts:write')
+ ->post('/posts', [PostController::class, 'store']);
+
+// ❌ Bad - check late in controller
+public function store(Request $request)
+{
+ $validated = $request->validate([...]); // Wasted work
+
+ if (!$request->user()->tokenCan('posts:write')) {
+ abort(403);
+ }
+}
+```
+
+## Testing Scopes
+
+```php
+use Tests\TestCase;
+use Laravel\Sanctum\Sanctum;
+
+class ScopeTest extends TestCase
+{
+ public function test_requires_write_scope(): void
+ {
+ $user = User::factory()->create();
+
+ // Token without write scope
+ Sanctum::actingAs($user, ['posts:read']);
+
+ $response = $this->postJson('/api/v1/posts', [
+ 'title' => 'Test Post',
+ ]);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_allows_with_correct_scope(): void
+ {
+ $user = User::factory()->create();
+
+ // Token with write scope
+ Sanctum::actingAs($user, ['posts:write']);
+
+ $response = $this->postJson('/api/v1/posts', [
+ 'title' => 'Test Post',
+ 'content' => 'Content',
+ ]);
+
+ $response->assertStatus(201);
+ }
+
+ public function test_wildcard_scope_grants_access(): void
+ {
+ $user = User::factory()->create();
+
+ Sanctum::actingAs($user, ['posts:*']);
+
+ $this->postJson('/api/v1/posts', [...])->assertStatus(201);
+ $this->putJson('/api/v1/posts/1', [...])->assertStatus(200);
+ $this->deleteJson('/api/v1/posts/1')->assertStatus(204);
+ }
+}
+```
+
+## Learn More
+
+- [Authentication →](/packages/api/authentication)
+- [Rate Limiting →](/packages/api/rate-limiting)
+- [API Reference →](/api/authentication)
diff --git a/docs/packages/api/webhook-integration.md b/docs/packages/api/webhook-integration.md
new file mode 100644
index 0000000..f659c40
--- /dev/null
+++ b/docs/packages/api/webhook-integration.md
@@ -0,0 +1,765 @@
+# Webhook Integration Guide
+
+This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers.
+
+## Overview
+
+Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data.
+
+**Key Features:**
+- HMAC-SHA256 signature verification
+- Automatic retries with exponential backoff
+- Timestamp validation for replay protection
+- Delivery tracking and manual retry
+
+## Webhook Payload Format
+
+All webhooks follow a consistent format:
+
+```json
+{
+ "id": "evt_abc123def456789",
+ "type": "post.created",
+ "created_at": "2026-01-15T10:30:00Z",
+ "data": {
+ "id": 123,
+ "title": "New Blog Post",
+ "status": "published",
+ "author_id": 42
+ },
+ "workspace_id": 456
+}
+```
+
+**Fields:**
+- `id` - Unique event identifier (use for idempotency)
+- `type` - Event type (e.g., `post.created`, `user.updated`)
+- `created_at` - ISO 8601 timestamp when the event occurred
+- `data` - Event-specific payload
+- `workspace_id` - Workspace that generated the event
+
+## Webhook Headers
+
+Every webhook request includes these headers:
+
+| Header | Description | Example |
+|--------|-------------|---------|
+| `Content-Type` | Always `application/json` | `application/json` |
+| `X-Webhook-Id` | Unique event ID | `evt_abc123def456` |
+| `X-Webhook-Event` | Event type | `post.created` |
+| `X-Webhook-Timestamp` | Unix timestamp | `1705312200` |
+| `X-Webhook-Signature` | HMAC-SHA256 signature | `a1b2c3d4e5f6...` |
+
+## Signature Verification
+
+**Always verify webhook signatures** to ensure requests are authentic and unmodified.
+
+### Signature Algorithm
+
+The signature is computed as:
+
+```
+signature = HMAC-SHA256(timestamp + "." + payload, secret)
+```
+
+Where:
+- `timestamp` is the value of `X-Webhook-Timestamp` header
+- `payload` is the raw request body (JSON string)
+- `secret` is your webhook signing secret
+
+### Verification Steps
+
+1. Get the signature and timestamp from headers
+2. Get the raw request body (do not parse JSON first)
+3. Compute expected signature: `HMAC-SHA256(timestamp + "." + body, secret)`
+4. Compare signatures using timing-safe comparison
+5. Verify timestamp is within 5 minutes of current time
+
+## Code Examples
+
+### PHP (Laravel)
+
+```php
+verifySignature($request)) {
+ Log::warning('Invalid webhook signature', [
+ 'ip' => $request->ip(),
+ ]);
+ return response()->json(['error' => 'Invalid signature'], 401);
+ }
+
+ // Step 2: Verify timestamp (replay protection)
+ if (!$this->verifyTimestamp($request)) {
+ Log::warning('Webhook timestamp too old');
+ return response()->json(['error' => 'Timestamp expired'], 401);
+ }
+
+ // Step 3: Check for duplicate events (idempotency)
+ $eventId = $request->input('id');
+ if ($this->isDuplicate($eventId)) {
+ // Already processed - return success to stop retries
+ return response()->json(['received' => true]);
+ }
+
+ // Step 4: Process the event
+ try {
+ $this->processEvent(
+ $request->input('type'),
+ $request->input('data')
+ );
+
+ // Mark event as processed
+ $this->markProcessed($eventId);
+
+ return response()->json(['received' => true]);
+
+ } catch (\Exception $e) {
+ Log::error('Webhook processing failed', [
+ 'event_id' => $eventId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ // Return 500 to trigger retry
+ return response()->json(['error' => 'Processing failed'], 500);
+ }
+ }
+
+ /**
+ * Verify the HMAC-SHA256 signature.
+ */
+ protected function verifySignature(Request $request): bool
+ {
+ $signature = $request->header('X-Webhook-Signature');
+ $timestamp = $request->header('X-Webhook-Timestamp');
+ $payload = $request->getContent();
+ $secret = config('services.webhooks.secret');
+
+ if (!$signature || !$timestamp) {
+ return false;
+ }
+
+ // Compute expected signature
+ $signedPayload = $timestamp . '.' . $payload;
+ $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
+
+ // Use timing-safe comparison
+ return hash_equals($expectedSignature, $signature);
+ }
+
+ /**
+ * Verify timestamp is within tolerance (5 minutes).
+ */
+ protected function verifyTimestamp(Request $request): bool
+ {
+ $timestamp = (int) $request->header('X-Webhook-Timestamp');
+ $tolerance = 300; // 5 minutes
+
+ return abs(time() - $timestamp) <= $tolerance;
+ }
+
+ /**
+ * Check if event was already processed.
+ */
+ protected function isDuplicate(string $eventId): bool
+ {
+ return cache()->has("webhook:processed:{$eventId}");
+ }
+
+ /**
+ * Mark event as processed (cache for 24 hours).
+ */
+ protected function markProcessed(string $eventId): void
+ {
+ cache()->put("webhook:processed:{$eventId}", true, now()->addDay());
+ }
+
+ /**
+ * Process the webhook event.
+ */
+ protected function processEvent(string $type, array $data): void
+ {
+ match ($type) {
+ 'post.created' => $this->handlePostCreated($data),
+ 'post.updated' => $this->handlePostUpdated($data),
+ 'post.deleted' => $this->handlePostDeleted($data),
+ 'user.created' => $this->handleUserCreated($data),
+ default => Log::info("Unhandled webhook type: {$type}"),
+ };
+ }
+
+ protected function handlePostCreated(array $data): void
+ {
+ // Sync to your database, trigger notifications, etc.
+ Log::info('Post created', $data);
+ }
+
+ protected function handlePostUpdated(array $data): void
+ {
+ Log::info('Post updated', $data);
+ }
+
+ protected function handlePostDeleted(array $data): void
+ {
+ Log::info('Post deleted', $data);
+ }
+
+ protected function handleUserCreated(array $data): void
+ {
+ Log::info('User created', $data);
+ }
+}
+```
+
+**Route registration:**
+
+```php
+// routes/api.php
+Route::post('/webhooks', [WebhookController::class, 'handle'])
+ ->middleware('throttle:100,1'); // Rate limit webhook endpoint
+```
+
+### JavaScript (Node.js/Express)
+
+```javascript
+const express = require('express');
+const crypto = require('crypto');
+const app = express();
+
+// Important: Use raw body for signature verification
+app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
+ const signature = req.headers['x-webhook-signature'];
+ const timestamp = req.headers['x-webhook-timestamp'];
+ const payload = req.body; // Raw buffer
+ const secret = process.env.WEBHOOK_SECRET;
+
+ // Step 1: Verify signature
+ if (!verifySignature(payload, signature, timestamp, secret)) {
+ console.warn('Invalid webhook signature');
+ return res.status(401).json({ error: 'Invalid signature' });
+ }
+
+ // Step 2: Verify timestamp
+ if (!verifyTimestamp(timestamp)) {
+ console.warn('Webhook timestamp too old');
+ return res.status(401).json({ error: 'Timestamp expired' });
+ }
+
+ // Step 3: Parse the event
+ let event;
+ try {
+ event = JSON.parse(payload.toString());
+ } catch (e) {
+ return res.status(400).json({ error: 'Invalid JSON' });
+ }
+
+ // Step 4: Check for duplicates
+ if (await isDuplicate(event.id)) {
+ return res.json({ received: true });
+ }
+
+ // Step 5: Process the event
+ try {
+ await processEvent(event.type, event.data);
+ await markProcessed(event.id);
+ res.json({ received: true });
+ } catch (e) {
+ console.error('Webhook processing failed:', e);
+ res.status(500).json({ error: 'Processing failed' });
+ }
+});
+
+function verifySignature(payload, signature, timestamp, secret) {
+ if (!signature || !timestamp) return false;
+
+ const signedPayload = timestamp + '.' + payload.toString();
+ const expectedSignature = crypto
+ .createHmac('sha256', secret)
+ .update(signedPayload)
+ .digest('hex');
+
+ // Timing-safe comparison
+ try {
+ return crypto.timingSafeEqual(
+ Buffer.from(signature),
+ Buffer.from(expectedSignature)
+ );
+ } catch {
+ return false;
+ }
+}
+
+function verifyTimestamp(timestamp) {
+ const tolerance = 300; // 5 minutes
+ const now = Math.floor(Date.now() / 1000);
+ return Math.abs(now - parseInt(timestamp)) <= tolerance;
+}
+
+// Redis-based duplicate detection
+const Redis = require('ioredis');
+const redis = new Redis();
+
+async function isDuplicate(eventId) {
+ return await redis.exists(`webhook:processed:${eventId}`);
+}
+
+async function markProcessed(eventId) {
+ await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400);
+}
+
+async function processEvent(type, data) {
+ switch (type) {
+ case 'post.created':
+ console.log('Post created:', data);
+ break;
+ case 'post.updated':
+ console.log('Post updated:', data);
+ break;
+ case 'post.deleted':
+ console.log('Post deleted:', data);
+ break;
+ default:
+ console.log(`Unhandled event type: ${type}`);
+ }
+}
+
+app.listen(3000);
+```
+
+### Python (Flask)
+
+```python
+import hmac
+import hashlib
+import time
+import json
+from functools import wraps
+from flask import Flask, request, jsonify
+import redis
+
+app = Flask(__name__)
+cache = redis.Redis()
+
+WEBHOOK_SECRET = 'your_webhook_secret'
+TIMESTAMP_TOLERANCE = 300 # 5 minutes
+
+def verify_webhook(f):
+ """Decorator to verify webhook signatures."""
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ signature = request.headers.get('X-Webhook-Signature')
+ timestamp = request.headers.get('X-Webhook-Timestamp')
+ payload = request.get_data()
+
+ # Verify signature
+ if not verify_signature(payload, signature, timestamp):
+ return jsonify({'error': 'Invalid signature'}), 401
+
+ # Verify timestamp
+ if not verify_timestamp(timestamp):
+ return jsonify({'error': 'Timestamp expired'}), 401
+
+ return f(*args, **kwargs)
+ return decorated
+
+
+def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
+ """Verify the HMAC-SHA256 signature."""
+ if not signature or not timestamp:
+ return False
+
+ signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
+ expected_signature = hmac.new(
+ WEBHOOK_SECRET.encode('utf-8'),
+ signed_payload.encode('utf-8'),
+ hashlib.sha256
+ ).hexdigest()
+
+ # Timing-safe comparison
+ return hmac.compare_digest(expected_signature, signature)
+
+
+def verify_timestamp(timestamp: str) -> bool:
+ """Verify timestamp is within tolerance."""
+ try:
+ ts = int(timestamp)
+ return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE
+ except (ValueError, TypeError):
+ return False
+
+
+def is_duplicate(event_id: str) -> bool:
+ """Check if event was already processed."""
+ return cache.exists(f"webhook:processed:{event_id}")
+
+
+def mark_processed(event_id: str) -> None:
+ """Mark event as processed (24 hour TTL)."""
+ cache.setex(f"webhook:processed:{event_id}", 86400, "1")
+
+
+@app.route('/webhooks', methods=['POST'])
+@verify_webhook
+def handle_webhook():
+ event = request.get_json()
+ event_id = event.get('id')
+ event_type = event.get('type')
+ data = event.get('data')
+
+ # Check for duplicates
+ if is_duplicate(event_id):
+ return jsonify({'received': True})
+
+ # Process the event
+ try:
+ process_event(event_type, data)
+ mark_processed(event_id)
+ return jsonify({'received': True})
+ except Exception as e:
+ app.logger.error(f"Webhook processing failed: {e}")
+ return jsonify({'error': 'Processing failed'}), 500
+
+
+def process_event(event_type: str, data: dict) -> None:
+ """Process webhook event based on type."""
+ handlers = {
+ 'post.created': handle_post_created,
+ 'post.updated': handle_post_updated,
+ 'post.deleted': handle_post_deleted,
+ 'user.created': handle_user_created,
+ }
+
+ handler = handlers.get(event_type)
+ if handler:
+ handler(data)
+ else:
+ app.logger.info(f"Unhandled event type: {event_type}")
+
+
+def handle_post_created(data: dict) -> None:
+ app.logger.info(f"Post created: {data}")
+
+
+def handle_post_updated(data: dict) -> None:
+ app.logger.info(f"Post updated: {data}")
+
+
+def handle_post_deleted(data: dict) -> None:
+ app.logger.info(f"Post deleted: {data}")
+
+
+def handle_user_created(data: dict) -> None:
+ app.logger.info(f"User created: {data}")
+
+
+if __name__ == '__main__':
+ app.run(port=3000)
+```
+
+## Retry Handling
+
+### Retry Schedule
+
+Failed webhook deliveries are automatically retried with exponential backoff:
+
+| Attempt | Delay | Total Time |
+|---------|-------|------------|
+| 1 | Immediate | 0 |
+| 2 | 1 minute | 1 minute |
+| 3 | 5 minutes | 6 minutes |
+| 4 | 30 minutes | 36 minutes |
+| 5 | 2 hours | 2h 36m |
+| 6 (final) | 24 hours | 26h 36m |
+
+After 6 failed attempts, the delivery is marked as permanently failed.
+
+### Triggering Retries
+
+A delivery is retried when your endpoint returns:
+- **5xx status codes** (server errors)
+- **Connection timeouts** (30 second default)
+- **Connection refused/failed**
+
+A delivery is **not** retried when:
+- **2xx status codes** (success)
+- **4xx status codes** (client errors - your endpoint rejected it)
+
+### Best Practices for Reliability
+
+**1. Return 200 Quickly**
+
+Process webhooks asynchronously to avoid timeouts:
+
+```php
+public function handle(Request $request)
+{
+ // Verify signature first
+ if (!$this->verifySignature($request)) {
+ return response()->json(['error' => 'Invalid signature'], 401);
+ }
+
+ // Queue for async processing
+ ProcessWebhook::dispatch($request->all());
+
+ // Return immediately
+ return response()->json(['received' => true]);
+}
+```
+
+**2. Handle Duplicates**
+
+Webhooks may be delivered more than once. Always check the event ID:
+
+```php
+public function handle(Request $request)
+{
+ $eventId = $request->input('id');
+
+ // Atomic check-and-set
+ if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) {
+ // Already processed
+ return response()->json(['received' => true]);
+ }
+
+ // Process the event...
+}
+```
+
+**3. Return 4xx for Permanent Failures**
+
+If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries:
+
+```php
+public function handle(Request $request)
+{
+ $eventType = $request->input('type');
+
+ // Unknown event type - don't retry
+ if (!in_array($eventType, $this->supportedEvents)) {
+ return response()->json(['error' => 'Unknown event type'], 400);
+ }
+
+ // Process...
+}
+```
+
+## Event Types
+
+### Common Events
+
+| Event | Description |
+|-------|-------------|
+| `{resource}.created` | Resource was created |
+| `{resource}.updated` | Resource was updated |
+| `{resource}.deleted` | Resource was deleted |
+| `{resource}.published` | Resource was published |
+| `{resource}.archived` | Resource was archived |
+
+### Wildcard Subscriptions
+
+Subscribe to all events for a resource:
+
+```php
+$webhook = WebhookEndpoint::create([
+ 'url' => 'https://your-app.com/webhooks',
+ 'events' => ['post.*'], // All post events
+ 'secret' => 'whsec_' . Str::random(32),
+]);
+```
+
+Subscribe to all events:
+
+```php
+$webhook = WebhookEndpoint::create([
+ 'url' => 'https://your-app.com/webhooks',
+ 'events' => ['*'], // All events
+ 'secret' => 'whsec_' . Str::random(32),
+]);
+```
+
+### High-Volume Events
+
+Some events are high-volume and opt-in only:
+
+- `link.clicked` - Link click tracking
+- `qrcode.scanned` - QR code scan tracking
+
+These must be explicitly included in the `events` array.
+
+## Testing Webhooks
+
+### Test Endpoint
+
+Use the test endpoint to verify your webhook handler:
+
+```bash
+curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \
+ -H "Authorization: Bearer sk_live_abc123"
+```
+
+This sends a test event to your endpoint.
+
+### Local Development
+
+For local development, use a tunnel service:
+
+**ngrok:**
+```bash
+ngrok http 3000
+# Use the https URL as your webhook endpoint
+```
+
+**Cloudflare Tunnel:**
+```bash
+cloudflared tunnel --url http://localhost:3000
+```
+
+### Mock Verification
+
+Test signature verification in isolation:
+
+```php
+// tests/Feature/WebhookTest.php
+public function test_verifies_valid_signature(): void
+{
+ $payload = json_encode([
+ 'id' => 'evt_test123',
+ 'type' => 'post.created',
+ 'data' => ['id' => 1, 'title' => 'Test'],
+ ]);
+
+ $timestamp = time();
+ $secret = 'test_secret';
+ $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
+
+ config(['services.webhooks.secret' => $secret]);
+
+ $response = $this->postJson('/webhooks', json_decode($payload, true), [
+ 'X-Webhook-Signature' => $signature,
+ 'X-Webhook-Timestamp' => $timestamp,
+ 'Content-Type' => 'application/json',
+ ]);
+
+ $response->assertOk();
+}
+
+public function test_rejects_invalid_signature(): void
+{
+ $response = $this->postJson('/webhooks', [
+ 'id' => 'evt_test123',
+ 'type' => 'post.created',
+ ], [
+ 'X-Webhook-Signature' => 'invalid',
+ 'X-Webhook-Timestamp' => time(),
+ ]);
+
+ $response->assertUnauthorized();
+}
+```
+
+## Troubleshooting
+
+### Signature Verification Fails
+
+**Common causes:**
+
+1. **Parsed JSON instead of raw body**
+ ```php
+ // Wrong - body has been modified
+ $payload = json_encode($request->all());
+
+ // Correct - raw body
+ $payload = $request->getContent();
+ ```
+
+2. **Different secrets**
+ - Check the secret matches exactly
+ - Ensure no extra whitespace
+
+3. **Encoding issues**
+ ```php
+ // Ensure UTF-8 encoding
+ $payload = $request->getContent();
+ $signedPayload = $timestamp . '.' . $payload;
+ ```
+
+### Deliveries Not Arriving
+
+1. **Check endpoint URL** - Must be publicly accessible (not localhost)
+2. **Check SSL certificate** - Must be valid and not expired
+3. **Check firewall rules** - Allow incoming HTTPS from webhook IPs
+4. **Check webhook is active** - Endpoints can be disabled after failures
+
+### Timeouts
+
+The default timeout is 30 seconds. If processing takes longer:
+
+```php
+// Queue long-running tasks
+public function handle(Request $request)
+{
+ // Quick signature check
+ if (!$this->verifySignature($request)) {
+ return response()->json(['error' => 'Invalid signature'], 401);
+ }
+
+ // Queue for async processing
+ ProcessWebhook::dispatch($request->all());
+
+ // Return immediately
+ return response()->json(['received' => true]);
+}
+```
+
+## Security Considerations
+
+### Always Verify Signatures
+
+Never skip signature verification, even in development:
+
+```php
+// DON'T DO THIS
+if (app()->environment('local')) {
+ return; // Skip verification
+}
+```
+
+### Use HTTPS
+
+Webhook endpoints must use HTTPS to protect:
+- The webhook secret in transit
+- Sensitive payload data
+
+### Protect Your Secret
+
+- Store in environment variables, not code
+- Rotate secrets periodically
+- Use different secrets per environment
+
+### Rate Limit Your Endpoint
+
+Protect against abuse:
+
+```php
+Route::post('/webhooks', [WebhookController::class, 'handle'])
+ ->middleware('throttle:100,1'); // 100 requests per minute
+```
+
+## Learn More
+
+- [Webhooks Overview](/packages/api/webhooks) - Creating webhook endpoints
+- [Authentication](/packages/api/authentication) - API key management
+- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits
diff --git a/docs/packages/api/webhooks.md b/docs/packages/api/webhooks.md
new file mode 100644
index 0000000..03852f6
--- /dev/null
+++ b/docs/packages/api/webhooks.md
@@ -0,0 +1,499 @@
+# Webhooks
+
+The API package provides event-driven webhooks with HMAC-SHA256 signatures, automatic retries, and delivery tracking.
+
+## Overview
+
+Webhooks allow your application to:
+- Send real-time notifications to external systems
+- Trigger workflows in other applications
+- Sync data across platforms
+- Build integrations without polling
+
+## Creating Webhooks
+
+### Basic Webhook
+
+```php
+use Mod\Api\Models\WebhookEndpoint;
+
+$webhook = WebhookEndpoint::create([
+ 'url' => 'https://your-app.com/webhooks',
+ 'events' => ['post.created', 'post.updated'],
+ 'secret' => 'whsec_'.Str::random(32),
+ 'workspace_id' => $workspace->id,
+ 'is_active' => true,
+]);
+```
+
+### With Filters
+
+```php
+$webhook = WebhookEndpoint::create([
+ 'url' => 'https://your-app.com/webhooks/posts',
+ 'events' => ['post.*'], // All post events
+ 'filters' => [
+ 'status' => 'published', // Only published posts
+ ],
+]);
+```
+
+## Dispatching Events
+
+### Manual Dispatch
+
+```php
+use Mod\Api\Services\WebhookService;
+
+$webhookService = app(WebhookService::class);
+
+$webhookService->dispatch('post.created', [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ 'url' => route('posts.show', $post),
+ 'published_at' => $post->published_at,
+]);
+```
+
+### From Model Events
+
+```php
+use Mod\Api\Services\WebhookService;
+
+class Post extends Model
+{
+ protected static function booted(): void
+ {
+ static::created(function (Post $post) {
+ app(WebhookService::class)->dispatch('post.created', [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ ]);
+ });
+
+ static::updated(function (Post $post) {
+ app(WebhookService::class)->dispatch('post.updated', [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ ]);
+ });
+ }
+}
+```
+
+### From Actions
+
+```php
+use Mod\Blog\Actions\CreatePost;
+use Mod\Api\Services\WebhookService;
+
+class CreatePost
+{
+ use Action;
+
+ public function handle(array $data): Post
+ {
+ $post = Post::create($data);
+
+ // Dispatch webhook
+ app(WebhookService::class)->dispatch('post.created', [
+ 'post' => $post->only(['id', 'title', 'slug']),
+ ]);
+
+ return $post;
+ }
+}
+```
+
+## Webhook Payload
+
+### Standard Format
+
+```json
+{
+ "id": "evt_abc123def456",
+ "type": "post.created",
+ "created_at": "2024-01-15T10:30:00Z",
+ "data": {
+ "object": {
+ "id": 123,
+ "title": "My Blog Post",
+ "url": "https://example.com/posts/my-blog-post"
+ }
+ },
+ "workspace_id": 456
+}
+```
+
+### Custom Payload
+
+```php
+$webhookService->dispatch('post.published', [
+ 'post_id' => $post->id,
+ 'title' => $post->title,
+ 'author' => [
+ 'id' => $post->author->id,
+ 'name' => $post->author->name,
+ ],
+ 'metadata' => [
+ 'published_at' => $post->published_at,
+ 'word_count' => str_word_count($post->content),
+ ],
+]);
+```
+
+## Webhook Signatures
+
+All webhook requests include HMAC-SHA256 signatures:
+
+### Request Headers
+
+```
+X-Webhook-Signature: sha256=abc123def456...
+X-Webhook-Timestamp: 1640995200
+X-Webhook-ID: evt_abc123
+```
+
+### Verifying Signatures
+
+```php
+use Mod\Api\Services\WebhookSignature;
+
+public function handle(Request $request)
+{
+ $payload = $request->getContent();
+ $signature = $request->header('X-Webhook-Signature');
+ $secret = $webhook->secret;
+
+ if (!WebhookSignature::verify($payload, $signature, $secret)) {
+ abort(401, 'Invalid signature');
+ }
+
+ // Process webhook...
+}
+```
+
+### Manual Verification
+
+```php
+$expectedSignature = 'sha256=' . hash_hmac(
+ 'sha256',
+ $payload,
+ $secret
+);
+
+if (!hash_equals($expectedSignature, $providedSignature)) {
+ abort(401);
+}
+```
+
+## Webhook Delivery
+
+### Automatic Retries
+
+Failed deliveries are automatically retried:
+
+```php
+// config/api.php
+'webhooks' => [
+ 'max_retries' => 3,
+ 'retry_delay' => 60, // seconds
+ 'timeout' => 10,
+],
+```
+
+Retry schedule:
+1. Immediate delivery
+2. After 1 minute
+3. After 5 minutes
+4. After 30 minutes
+
+### Delivery Status
+
+```php
+$deliveries = $webhook->deliveries()
+ ->latest()
+ ->limit(10)
+ ->get();
+
+foreach ($deliveries as $delivery) {
+ echo $delivery->status; // success, failed, pending
+ echo $delivery->status_code; // HTTP status code
+ echo $delivery->attempts; // Number of attempts
+ echo $delivery->response_body; // Response from endpoint
+}
+```
+
+### Manual Retry
+
+```php
+use Mod\Api\Models\WebhookDelivery;
+
+$delivery = WebhookDelivery::find($id);
+
+if ($delivery->isFailed()) {
+ $delivery->retry();
+}
+```
+
+## Webhook Events
+
+### Common Events
+
+| Event | Description |
+|-------|-------------|
+| `{resource}.created` | Resource created |
+| `{resource}.updated` | Resource updated |
+| `{resource}.deleted` | Resource deleted |
+| `{resource}.published` | Resource published |
+| `{resource}.archived` | Resource archived |
+
+### Wildcards
+
+```php
+// All post events
+'events' => ['post.*']
+
+// All events
+'events' => ['*']
+
+// Specific events
+'events' => ['post.created', 'post.published']
+```
+
+## Testing Webhooks
+
+### Test Endpoint
+
+```php
+use Mod\Api\Models\WebhookEndpoint;
+
+$webhook = WebhookEndpoint::find($id);
+
+$result = $webhook->test([
+ 'test' => true,
+ 'message' => 'This is a test webhook',
+]);
+
+if ($result['success']) {
+ echo "Test successful! Status: {$result['status_code']}";
+} else {
+ echo "Test failed: {$result['error']}";
+}
+```
+
+### Mock Webhooks in Tests
+
+```php
+ 'Test']);
+
+ Webhooks::assertDispatched('post.created', function ($event, $payload) {
+ return $payload['id'] === $post->id;
+ });
+ }
+}
+```
+
+## Webhook Consumers
+
+### Receiving Webhooks (PHP)
+
+```php
+verifySignature($request)) {
+ abort(401, 'Invalid signature');
+ }
+
+ $event = $request->input('type');
+ $data = $request->input('data');
+
+ match ($event) {
+ 'post.created' => $this->handlePostCreated($data),
+ 'post.updated' => $this->handlePostUpdated($data),
+ default => null,
+ };
+
+ return response()->json(['received' => true]);
+ }
+
+ protected function verifySignature(Request $request): bool
+ {
+ $payload = $request->getContent();
+ $signature = $request->header('X-Webhook-Signature');
+ $secret = config('webhooks.secret');
+
+ $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
+
+ return hash_equals($expected, $signature);
+ }
+}
+```
+
+### Receiving Webhooks (JavaScript/Node.js)
+
+```javascript
+const express = require('express');
+const crypto = require('crypto');
+
+app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
+ const payload = req.body;
+ const signature = req.headers['x-webhook-signature'];
+ const secret = process.env.WEBHOOK_SECRET;
+
+ // Verify signature
+ const expected = 'sha256=' + crypto
+ .createHmac('sha256', secret)
+ .update(payload)
+ .digest('hex');
+
+ if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
+ return res.status(401).send('Invalid signature');
+ }
+
+ const event = JSON.parse(payload);
+
+ switch (event.type) {
+ case 'post.created':
+ handlePostCreated(event.data);
+ break;
+ case 'post.updated':
+ handlePostUpdated(event.data);
+ break;
+ }
+
+ res.json({received: true});
+});
+```
+
+## Webhook Management UI
+
+### List Webhooks
+
+```php
+$webhooks = WebhookEndpoint::where('workspace_id', $workspace->id)->get();
+```
+
+### Enable/Disable
+
+```php
+$webhook->update(['is_active' => false]); // Disable
+$webhook->update(['is_active' => true]); // Enable
+```
+
+### View Deliveries
+
+```php
+$deliveries = $webhook->deliveries()
+ ->with('webhookEndpoint')
+ ->latest()
+ ->paginate(50);
+```
+
+## Best Practices
+
+### 1. Verify Signatures
+
+```php
+// ✅ Good - always verify
+if (!WebhookSignature::verify($payload, $signature, $secret)) {
+ abort(401);
+}
+```
+
+### 2. Return 200 Quickly
+
+```php
+// ✅ Good - queue long-running tasks
+public function handle(Request $request)
+{
+ // Verify signature
+ if (!$this->verifySignature($request)) {
+ abort(401);
+ }
+
+ // Queue processing
+ ProcessWebhook::dispatch($request->all());
+
+ return response()->json(['received' => true]);
+}
+```
+
+### 3. Handle Idempotency
+
+```php
+// ✅ Good - check for duplicate events
+public function handle(Request $request)
+{
+ $eventId = $request->input('id');
+
+ if (ProcessedWebhook::where('event_id', $eventId)->exists()) {
+ return response()->json(['received' => true]); // Already processed
+ }
+
+ // Process webhook...
+
+ ProcessedWebhook::create(['event_id' => $eventId]);
+}
+```
+
+### 4. Use Webhook Secrets
+
+```php
+// ✅ Good - secure secret
+'secret' => 'whsec_' . Str::random(32)
+
+// ❌ Bad - weak secret
+'secret' => 'password123'
+```
+
+## Troubleshooting
+
+### Webhook Not Firing
+
+1. Check if webhook is active: `$webhook->is_active`
+2. Verify event name matches: `'post.created'` not `'posts.created'`
+3. Check workspace context is set
+4. Review event filters
+
+### Delivery Failures
+
+1. Check endpoint URL is reachable
+2. Verify SSL certificate is valid
+3. Check firewall/IP whitelist
+4. Review timeout settings
+
+### Signature Verification Fails
+
+1. Ensure using raw request body (not parsed JSON)
+2. Check secret matches on both sides
+3. Verify using same hashing algorithm (SHA-256)
+4. Check for whitespace/encoding issues
+
+## Learn More
+
+- [API Authentication →](/packages/api/authentication)
+- [Webhook Security →](/api/authentication#webhook-signatures)
+- [API Reference →](/api/endpoints#webhook-endpoints)
diff --git a/docs/packages/commerce/architecture.md b/docs/packages/commerce/architecture.md
new file mode 100644
index 0000000..a459a8a
--- /dev/null
+++ b/docs/packages/commerce/architecture.md
@@ -0,0 +1,402 @@
+---
+title: Architecture
+description: Technical architecture of the core-commerce package
+updated: 2026-01-29
+---
+
+# Commerce Architecture
+
+This document describes the technical architecture of the `core-commerce` package, which provides billing, subscriptions, and payment processing for the Host UK platform.
+
+## Overview
+
+The commerce module implements a multi-gateway payment system supporting cryptocurrency (BTCPay) and traditional card payments (Stripe). It handles the complete commerce lifecycle from checkout to recurring billing, dunning, and refunds.
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Commerce Module │
+├─────────────────────────────────────────────────────────────────┤
+│ Services Layer │
+│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
+│ │ Commerce │ │ Subscription │ │ Dunning │ │
+│ │ Service │ │ Service │ │ Service │ │
+│ └─────────────┘ └──────────────┘ └───────────────┘ │
+│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
+│ │ Invoice │ │ Coupon │ │ Tax │ │
+│ │ Service │ │ Service │ │ Service │ │
+│ └─────────────┘ └──────────────┘ └───────────────┘ │
+├─────────────────────────────────────────────────────────────────┤
+│ Gateway Layer │
+│ ┌──────────────────────┐ ┌──────────────────────┐ │
+│ │ BTCPayGateway │ │ StripeGateway │ │
+│ │ (Primary) │ │ (Secondary) │ │
+│ └──────────────────────┘ └──────────────────────┘ │
+│ │ │ │
+│ └────────────┬─────────────┘ │
+│ │ │
+│ ┌────────────▼─────────────┐ │
+│ │ PaymentGatewayContract │ │
+│ └──────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Core Concepts
+
+### Orderable Interface
+
+The commerce system uses polymorphic relationships via the `Orderable` contract. Both `Workspace` and `User` models can place orders, enabling:
+
+- **Workspace orders**: Subscription packages, team features
+- **User orders**: Individual boosts, one-time purchases
+
+```php
+interface Orderable
+{
+ public function getBillingName(): string;
+ public function getBillingEmail(): string;
+ public function getBillingAddress(): array;
+ public function getTaxCountry(): ?string;
+}
+```
+
+### Order Lifecycle
+
+```
+┌──────────┐ ┌────────────┐ ┌──────────┐ ┌────────┐
+│ pending │───▶│ processing │───▶│ paid │───▶│refunded│
+└──────────┘ └────────────┘ └──────────┘ └────────┘
+ │ │
+ │ │
+ ▼ ▼
+┌──────────┐ ┌──────────┐
+│cancelled │ │ failed │
+└──────────┘ └──────────┘
+```
+
+1. **pending**: Order created, awaiting checkout
+2. **processing**: Customer redirected to payment gateway
+3. **paid**: Payment confirmed, entitlements provisioned
+4. **failed**: Payment declined or expired
+5. **cancelled**: Customer abandoned checkout
+6. **refunded**: Full refund processed
+
+### Subscription States
+
+```
+┌────────┐ ┌──────────┐ ┌────────┐ ┌───────────┐
+│ active │───▶│ past_due │───▶│ paused │───▶│ cancelled │
+└────────┘ └──────────┘ └────────┘ └───────────┘
+ │ │ │
+ ▼ │ │
+┌──────────┐ │ │
+│ trialing │────────┘ │
+└──────────┘ │
+ │ │
+ └─────────────────────────────┘
+```
+
+- **active**: Subscription in good standing
+- **trialing**: Within trial period (no payment required)
+- **past_due**: Payment failed, within retry window
+- **paused**: Billing paused (dunning or user-initiated)
+- **cancelled**: Subscription ended
+
+## Service Layer
+
+### CommerceService
+
+Main orchestration service. Coordinates order creation, checkout, and fulfillment.
+
+```php
+// Create an order
+$order = $commerce->createOrder($workspace, $package, 'monthly', $coupon);
+
+// Create checkout session (redirects to gateway)
+$checkout = $commerce->createCheckout($order, 'btcpay', $successUrl, $cancelUrl);
+
+// Fulfill order after payment (called by webhook)
+$commerce->fulfillOrder($order, $payment);
+```
+
+Key responsibilities:
+- Gateway selection and initialization
+- Customer management across gateways
+- Order-to-entitlement provisioning
+- Currency formatting and conversion
+
+### SubscriptionService
+
+Manages subscription lifecycle without gateway interaction.
+
+```php
+// Create local subscription record
+$subscription = $subscriptions->create($workspacePackage, 'monthly');
+
+// Handle plan changes with proration
+$result = $subscriptions->changePlan($subscription, $newPackage, prorate: true);
+
+// Pause/unpause with limits
+$subscriptions->pause($subscription);
+$subscriptions->unpause($subscription);
+```
+
+Proration calculation:
+```
+creditAmount = currentPrice * (daysRemaining / totalPeriodDays)
+proratedNewCost = newPrice * (daysRemaining / totalPeriodDays)
+netAmount = proratedNewCost - creditAmount
+```
+
+### DunningService
+
+Handles failed payment recovery with exponential backoff.
+
+```
+Day 0: Payment fails → subscription marked past_due
+Day 1: First retry
+Day 3: Second retry
+Day 7: Third retry → subscription paused
+Day 14: Workspace suspended (features restricted)
+Day 30: Subscription cancelled
+```
+
+Configuration in `config.php`:
+```php
+'dunning' => [
+ 'retry_days' => [1, 3, 7],
+ 'suspend_after_days' => 14,
+ 'cancel_after_days' => 30,
+ 'initial_grace_hours' => 24,
+],
+```
+
+### TaxService
+
+Jurisdiction-based tax calculation supporting:
+- UK VAT (20%)
+- EU VAT via VIES validation
+- US state sales tax (nexus-based)
+- Australian GST (10%)
+
+B2B reverse charge is applied automatically when a valid VAT number is provided for EU customers.
+
+```php
+$taxResult = $taxService->calculate($workspace, $amount);
+// Returns: TaxResult with taxAmount, taxRate, jurisdiction, isExempt
+```
+
+## Payment Gateways
+
+### PaymentGatewayContract
+
+All gateways implement this interface ensuring consistent behavior:
+
+```php
+interface PaymentGatewayContract
+{
+ // Identity
+ public function getIdentifier(): string;
+ public function isEnabled(): bool;
+
+ // Customer management
+ public function createCustomer(Workspace $workspace): string;
+
+ // Checkout
+ public function createCheckoutSession(Order $order, ...): array;
+ public function getCheckoutSession(string $sessionId): array;
+
+ // Payments
+ public function charge(Workspace $workspace, int $amountCents, ...): Payment;
+ public function chargePaymentMethod(PaymentMethod $pm, ...): Payment;
+
+ // Subscriptions
+ public function createSubscription(Workspace $workspace, ...): Subscription;
+ public function cancelSubscription(Subscription $sub, bool $immediately): void;
+
+ // Webhooks
+ public function verifyWebhookSignature(string $payload, string $sig): bool;
+ public function parseWebhookEvent(string $payload): array;
+}
+```
+
+### BTCPayGateway (Primary)
+
+Cryptocurrency payment gateway supporting BTC, LTC, XMR.
+
+**Characteristics:**
+- No saved payment methods (each payment is unique)
+- No automatic recurring billing (requires customer action)
+- Invoice-based workflow with expiry
+- HMAC signature verification for webhooks
+
+**Webhook Events:**
+- `InvoiceCreated` → No action
+- `InvoiceReceivedPayment` → Order status: processing
+- `InvoiceProcessing` → Waiting for confirmations
+- `InvoiceSettled` → Fulfill order
+- `InvoiceExpired` → Mark order failed
+
+### StripeGateway (Secondary)
+
+Traditional card payment gateway.
+
+**Characteristics:**
+- Saved payment methods for recurring
+- Automatic subscription billing
+- Setup intents for card-on-file
+- Stripe Customer Portal integration
+
+**Webhook Events:**
+- `checkout.session.completed` → Fulfill order
+- `invoice.paid` → Renew subscription
+- `invoice.payment_failed` → Trigger dunning
+- `customer.subscription.deleted` → Revoke entitlements
+
+## Data Models
+
+### Entity Relationship
+
+```
+┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+│ Workspace │────▶│ Order │────▶│ OrderItem │
+└─────────────┘ └─────────────┘ └─────────────┘
+ │ │
+ │ ▼
+ │ ┌─────────────┐ ┌─────────────┐
+ │ │ Invoice │────▶│InvoiceItem │
+ │ └─────────────┘ └─────────────┘
+ │ │
+ │ ▼
+ │ ┌─────────────┐ ┌─────────────┐
+ └───────────▶│ Payment │────▶│ Refund │
+ └─────────────┘ └─────────────┘
+ │
+ ▼
+┌─────────────┐ ┌─────────────┐
+│ Coupon │────▶│ CouponUsage │
+└─────────────┘ └─────────────┘
+```
+
+### Multi-Entity Commerce (M1/M2/M3)
+
+The commerce module supports a hierarchical entity structure:
+
+- **M1 (Master Company)**: Source of truth, owns product catalog
+- **M2 (Facade/Storefront)**: Selects from M1 catalog, can override content
+- **M3 (Dropshipper)**: Full inheritance, no management responsibility
+
+```
+ ┌─────────┐
+ │ M1 │ ← Product catalog owner
+ └────┬────┘
+ │
+ ┌────────┴────────┐
+ │ │
+┌─────▼─────┐ ┌─────▼─────┐
+│ M2 │ │ M2 │ ← Storefronts
+└─────┬─────┘ └───────────┘
+ │
+┌─────▼─────┐
+│ M3 │ ← Dropshipper
+└───────────┘
+```
+
+Permission matrix controls which operations each entity type can perform, with a "training mode" for undefined permissions.
+
+## Event System
+
+### Domain Events
+
+```php
+// Dispatched automatically on model changes
+SubscriptionCreated::class → RewardAgentReferralOnSubscription
+SubscriptionRenewed::class → ResetUsageOnRenewal
+OrderPaid::class → CreateReferralCommission
+```
+
+### Listeners
+
+- `ProvisionSocialHostSubscription`: Product-specific provisioning logic
+- `RewardAgentReferralOnSubscription`: Attribute referral for new subscriptions
+- `ResetUsageOnRenewal`: Clear usage counters on billing period reset
+- `CreateReferralCommission`: Calculate affiliate commission on paid orders
+
+## Directory Structure
+
+```
+core-commerce/
+├── Boot.php # ServiceProvider, event registration
+├── config.php # All configuration (currencies, gateways, tax)
+├── Concerns/ # Traits for models
+├── Console/ # Artisan commands (dunning, reminders)
+├── Contracts/ # Interfaces (Orderable)
+├── Controllers/ # HTTP controllers
+│ ├── Api/ # REST API endpoints
+│ └── Webhooks/ # Gateway webhook handlers
+├── Data/ # DTOs and value objects
+├── Events/ # Domain events
+├── Exceptions/ # Custom exceptions
+├── Jobs/ # Queue jobs
+├── Lang/ # Translations
+├── Listeners/ # Event listeners
+├── Mail/ # Mailable classes
+├── Mcp/ # MCP tool handlers
+├── Middleware/ # HTTP middleware
+├── Migrations/ # Database migrations
+├── Models/ # Eloquent models
+├── Notifications/ # Laravel notifications
+├── routes/ # Route definitions
+├── Services/ # Business logic layer
+│ └── PaymentGateway/ # Gateway implementations
+├── tests/ # Pest tests
+└── View/ # Blade templates and Livewire components
+ ├── Blade/ # Blade templates
+ └── Modal/ # Livewire components (Admin/Web)
+```
+
+## Configuration
+
+All commerce configuration lives in `config.php`:
+
+```php
+return [
+ 'currency' => 'GBP', // Default currency
+ 'currencies' => [...], // Supported currencies, exchange rates
+ 'gateways' => [
+ 'btcpay' => [...], // Primary gateway
+ 'stripe' => [...], // Secondary gateway
+ ],
+ 'billing' => [...], // Invoice prefixes, due days
+ 'dunning' => [...], // Retry schedule, suspension timing
+ 'tax' => [...], // Tax rates, VAT validation
+ 'subscriptions' => [...], // Proration, pause limits
+ 'checkout' => [...], // Session TTL, country restrictions
+ 'features' => [...], // Toggle coupons, refunds, trials
+ 'usage_billing' => [...], // Metered billing settings
+ 'matrix' => [...], // M1/M2/M3 permission matrix
+];
+```
+
+## Testing
+
+Tests use Pest with `RefreshDatabase` trait:
+
+```bash
+# Run all tests
+composer test
+
+# Run specific test file
+vendor/bin/pest tests/Feature/CheckoutFlowTest.php
+
+# Run tests matching pattern
+vendor/bin/pest --filter="proration"
+```
+
+Test categories:
+- `CheckoutFlowTest`: End-to-end order flow
+- `SubscriptionServiceTest`: Subscription lifecycle, proration
+- `DunningServiceTest`: Payment recovery flows
+- `WebhookTest`: Gateway webhook handling
+- `TaxServiceTest`: Tax calculation, VAT validation
+- `CouponServiceTest`: Discount application
+- `RefundServiceTest`: Refund processing
diff --git a/docs/packages/commerce/index.md b/docs/packages/commerce/index.md
new file mode 100644
index 0000000..990a02a
--- /dev/null
+++ b/docs/packages/commerce/index.md
@@ -0,0 +1,34 @@
+---
+title: Commerce Package
+navTitle: Commerce
+navOrder: 30
+---
+
+# Commerce Package
+
+Billing, subscriptions, and payment processing for the Host UK platform.
+
+## Overview
+
+The commerce module implements a multi-gateway payment system supporting cryptocurrency (BTCPay) and traditional card payments (Stripe). It handles the complete commerce lifecycle from checkout to recurring billing, dunning, and refunds.
+
+## Features
+
+- **Multi-gateway payments** - BTCPay (primary) and Stripe (secondary)
+- **Subscriptions** - Recurring billing with plan management
+- **Invoicing** - Automatic invoice generation and delivery
+- **Dunning** - Failed payment recovery workflows
+- **Coupons** - Discount codes and promotional pricing
+- **Tax handling** - VAT/GST calculation and compliance
+
+## Installation
+
+```bash
+composer require host-uk/core-commerce
+```
+
+## Documentation
+
+- [Architecture](./architecture) - Technical architecture and design
+- [Security](./security) - Payment security and PCI compliance
+- [Webhooks](./webhooks) - Payment gateway webhook handling
\ No newline at end of file
diff --git a/docs/packages/commerce/security.md b/docs/packages/commerce/security.md
new file mode 100644
index 0000000..e69b7e0
--- /dev/null
+++ b/docs/packages/commerce/security.md
@@ -0,0 +1,327 @@
+---
+title: Security
+description: Security considerations and audit notes for core-commerce
+updated: 2026-01-29
+---
+
+# Security Considerations
+
+This document outlines security controls, known risks, and recommendations for the `core-commerce` package.
+
+## Authentication & Authorisation
+
+### API Authentication
+
+| Endpoint Type | Authentication Method | Notes |
+|--------------|----------------------|-------|
+| Webhooks (`/api/webhooks/*`) | HMAC signature | Gateway-specific verification |
+| Billing API (`/api/commerce/*`) | Laravel `auth` middleware | Session/Sanctum token |
+| Provisioning API | Bearer token (planned) | Currently commented out |
+
+### Webhook Security
+
+Both payment gateways use HMAC signature verification:
+
+**BTCPay:**
+```php
+// Signature in BTCPay-Sig header
+$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);
+hash_equals($expectedSignature, $providedSignature);
+```
+
+**Stripe:**
+```php
+// Uses Stripe SDK signature verification
+\Stripe\Webhook::constructEvent($payload, $signature, $webhookSecret);
+```
+
+### Current Gaps
+
+1. **No idempotency enforcement** - Webhook handlers check order state (`isPaid()`) but don't store processed event IDs. Replay attacks within the state-check window are possible.
+
+2. **No IP allowlisting** - Webhook endpoints accept connections from any IP. Consider adding gateway IP ranges to allowlist.
+
+3. **Rate limiting is global** - Current throttle (`120,1`) applies globally, not per-IP. A malicious actor could exhaust the limit.
+
+## Data Protection
+
+### Sensitive Data Handling
+
+| Data Type | Storage | Protection |
+|-----------|---------|------------|
+| Card details | Never stored | Handled by gateways via redirect |
+| Gateway API keys | Environment variables | Not in codebase |
+| Webhook secrets | Environment variables | Used for HMAC |
+| Tax IDs (VAT numbers) | Encrypted column recommended | Currently plain text |
+| Billing addresses | Database JSON column | Consider encryption |
+
+### PCI DSS Compliance
+
+The commerce module is designed to be **PCI DSS SAQ A** compliant:
+
+- No card data ever touches Host UK servers
+- Checkout redirects to hosted payment pages (BTCPay/Stripe)
+- Only tokenized references (customer IDs, payment method IDs) are stored
+- No direct card number input in application
+
+### GDPR Considerations
+
+Personal data in commerce models:
+- `orders.billing_name`, `billing_email`, `billing_address`
+- `invoices.billing_*` fields
+- `referrals.ip_address`, `user_agent`
+
+**Recommendations:**
+- Implement data export for billing history (right of access)
+- Add retention policy for old orders/invoices
+- Hash or truncate IP addresses after 90 days
+- Document lawful basis for processing (contract performance)
+
+## Input Validation
+
+### Current Controls
+
+```php
+// Coupon codes normalized
+$data['code'] = strtoupper($data['code']);
+
+// Order totals calculated server-side
+$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
+$total = $subtotal - $discountAmount + $setupFee + $taxResult->taxAmount;
+
+// Gateway responses logged without sensitive data
+protected function sanitiseErrorMessage($response): string
+```
+
+### Validation Gaps
+
+1. **Billing address structure** - Accepted as array without schema validation
+2. **Coupon code length** - No maximum length enforcement
+3. **Metadata fields** - JSON columns accept arbitrary structure
+
+### Recommendations
+
+```php
+// Add validation rules
+$rules = [
+ 'billing_address.line1' => ['required', 'string', 'max:255'],
+ 'billing_address.city' => ['required', 'string', 'max:100'],
+ 'billing_address.country' => ['required', 'string', 'size:2'],
+ 'billing_address.postal_code' => ['required', 'string', 'max:20'],
+ 'coupon_code' => ['nullable', 'string', 'max:32', 'alpha_dash'],
+];
+```
+
+## Transaction Security
+
+### Idempotency
+
+Order creation supports idempotency keys:
+
+```php
+if ($idempotencyKey) {
+ $existingOrder = Order::where('idempotency_key', $idempotencyKey)->first();
+ if ($existingOrder) {
+ return $existingOrder;
+ }
+}
+```
+
+**Gap:** Webhooks don't use idempotency. Add `WebhookEvent` lookup:
+
+```php
+if (WebhookEvent::where('idempotency_key', $event['id'])->exists()) {
+ return response('Already processed', 200);
+}
+```
+
+### Race Conditions
+
+**Identified risks:**
+
+1. **Concurrent subscription operations** - Pause/unpause/cancel without locks
+2. **Coupon redemption** - `incrementUsage()` without atomic check
+3. **Payout requests** - Commission assignment without row locks
+
+**Mitigation:** Add `FOR UPDATE` locks or use atomic operations:
+
+```php
+// Use DB::transaction with locking
+$commission = ReferralCommission::lockForUpdate()
+ ->where('id', $commissionId)
+ ->where('status', 'matured')
+ ->first();
+```
+
+### Amount Verification
+
+**Current state:** BTCPay webhook trusts order total without verifying against gateway response.
+
+**Risk:** Under/overpayment handling undefined.
+
+**Recommendation:**
+```php
+$settledAmount = $invoiceData['raw']['amount'] ?? null;
+if ($settledAmount !== null && abs($settledAmount - $order->total) > 0.01) {
+ Log::warning('Payment amount mismatch', [
+ 'order_total' => $order->total,
+ 'settled_amount' => $settledAmount,
+ ]);
+ // Handle partial payment or overpayment
+}
+```
+
+## Fraud Prevention
+
+### Current Controls
+
+- Checkout session TTL (30 minutes default)
+- Rate limiting on API endpoints
+- Idempotency keys for order creation
+
+### Missing Controls
+
+1. **Velocity checks** - No detection of rapid-fire order attempts
+2. **Geo-blocking** - No IP geolocation validation against billing country
+3. **Card testing detection** - No small-amount charge pattern detection
+4. **Device fingerprinting** - No device/browser tracking
+
+### Recommendations
+
+```php
+// Add CheckoutRateLimiter to createCheckout
+$rateLimiter = app(CheckoutRateLimiter::class);
+if (!$rateLimiter->attempt($workspace->id)) {
+ throw new TooManyCheckoutAttemptsException();
+}
+
+// Consider Stripe Radar for card payments
+'stripe' => [
+ 'radar_enabled' => true,
+ 'block_threshold' => 75, // Block if risk score > 75
+],
+```
+
+## Audit Logging
+
+### What's Logged
+
+- Order status changes via `LogsActivity` trait
+- Subscription status changes via `LogsActivity` trait
+- Webhook events via `WebhookLogger` service
+- Payment failures and retries
+
+### What's Not Logged
+
+- Failed authentication attempts on billing API
+- Coupon validation failures
+- Tax ID validation API calls
+- Admin actions on refunds/credit notes
+
+### Recommendations
+
+Add audit events for:
+```php
+// Sensitive operations
+activity('commerce')
+ ->causedBy($admin)
+ ->performedOn($refund)
+ ->withProperties(['reason' => $reason])
+ ->log('Refund processed');
+```
+
+## Secrets Management
+
+### Environment Variables
+
+```bash
+# Gateway credentials
+BTCPAY_URL=https://pay.host.uk.com
+BTCPAY_STORE_ID=xxx
+BTCPAY_API_KEY=xxx
+BTCPAY_WEBHOOK_SECRET=xxx
+
+STRIPE_KEY=pk_xxx
+STRIPE_SECRET=sk_xxx
+STRIPE_WEBHOOK_SECRET=whsec_xxx
+
+# Tax API credentials
+COMMERCE_EXCHANGE_RATE_API_KEY=xxx
+```
+
+### Key Rotation
+
+No automated key rotation currently implemented.
+
+**Recommendations:**
+- Store credentials in secrets manager (AWS Secrets Manager, HashiCorp Vault)
+- Implement webhook secret rotation with grace period
+- Alert on API key exposure in logs
+
+## Security Checklist
+
+### Before Production
+
+- [ ] Webhook secrets are unique per environment
+- [ ] Rate limiting tuned for expected traffic
+- [ ] Error messages don't leak internal details
+- [ ] API keys not in version control
+- [ ] SSL/TLS required for all endpoints
+
+### Ongoing
+
+- [ ] Monitor webhook failure rates
+- [ ] Review failed payment patterns weekly
+- [ ] Audit refund activity monthly
+- [ ] Update gateway SDKs quarterly
+- [ ] Penetration test annually
+
+## Incident Response
+
+### Compromised API Key
+
+1. Revoke key immediately in gateway dashboard
+2. Generate new key
+3. Update environment variable
+4. Restart application
+5. Audit recent transactions for anomalies
+
+### Webhook Secret Leaked
+
+1. Generate new secret in gateway
+2. Update both old and new in config (grace period)
+3. Monitor for invalid signature attempts
+4. Remove old secret after 24 hours
+
+### Suspected Fraud
+
+1. Pause affected subscription
+2. Flag orders for manual review
+3. Contact gateway for chargeback advice
+4. Document in incident log
+
+## Third-Party Dependencies
+
+### Gateway SDKs
+
+| Package | Version | Security Notes |
+|---------|---------|----------------|
+| `stripe/stripe-php` | ^12.0 | Keep updated for security patches |
+
+### Other Dependencies
+
+- `spatie/laravel-activitylog` - Audit logging
+- `barryvdh/laravel-dompdf` - PDF generation (ensure no user input in HTML)
+
+### Dependency Audit
+
+Run regularly:
+```bash
+composer audit
+```
+
+## Contact
+
+Report security issues to: security@host.uk.com
+
+Do not open public issues for security vulnerabilities.
diff --git a/docs/packages/commerce/webhooks.md b/docs/packages/commerce/webhooks.md
new file mode 100644
index 0000000..cf66a92
--- /dev/null
+++ b/docs/packages/commerce/webhooks.md
@@ -0,0 +1,387 @@
+---
+title: Webhooks
+description: Payment gateway webhook handling documentation
+updated: 2026-01-29
+---
+
+# Webhook Handling
+
+This document describes how payment gateway webhooks are processed in the commerce module.
+
+## Overview
+
+Payment gateways notify the application of payment events via webhooks. These are HTTP POST requests sent to predefined endpoints when payment state changes.
+
+```
+┌──────────────┐ ┌──────────────┐ ┌──────────────┐
+│ BTCPay │ │ Host UK │ │ Stripe │
+│ Server │ │ Commerce │ │ API │
+└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
+ │ │ │
+ │ POST /api/webhooks/ │ │
+ │ btcpay │ │
+ │ ───────────────────────▶│ │
+ │ │ │
+ │ │ POST /api/webhooks/ │
+ │ │ stripe │
+ │ │◀─────────────────────────
+ │ │ │
+```
+
+## Endpoints
+
+| Gateway | Endpoint | Signature Header |
+|---------|----------|------------------|
+| BTCPay | `POST /api/webhooks/btcpay` | `BTCPay-Sig` |
+| Stripe | `POST /api/webhooks/stripe` | `Stripe-Signature` |
+
+Both endpoints:
+- Rate limited: 120 requests per minute
+- No authentication middleware (signature verification only)
+- Return 200 for successful processing (even if event is skipped)
+- Return 401 for invalid signatures
+- Return 500 for processing errors (triggers gateway retry)
+
+## BTCPay Webhooks
+
+### Configuration
+
+In BTCPay Server dashboard:
+1. Navigate to Store Settings > Webhooks
+2. Create webhook with URL: `https://yourdomain.com/api/webhooks/btcpay`
+3. Select events to send
+4. Copy webhook secret to `BTCPAY_WEBHOOK_SECRET`
+
+### Event Types
+
+| BTCPay Event | Mapped Type | Action |
+|--------------|-------------|--------|
+| `InvoiceCreated` | `invoice.created` | No action |
+| `InvoiceReceivedPayment` | `invoice.payment_received` | Order → processing |
+| `InvoiceProcessing` | `invoice.processing` | Order → processing |
+| `InvoiceSettled` | `invoice.paid` | Fulfil order |
+| `InvoiceExpired` | `invoice.expired` | Order → failed |
+| `InvoiceInvalid` | `invoice.failed` | Order → failed |
+
+### Processing Flow
+
+```php
+// BTCPayWebhookController::handle()
+
+1. Verify signature
+ └── 401 if invalid
+
+2. Parse event
+ └── Extract type, invoice ID, metadata
+
+3. Log webhook event
+ └── WebhookLogger creates audit record
+
+4. Route to handler (in transaction)
+ ├── invoice.paid → handleSettled()
+ ├── invoice.expired → handleExpired()
+ └── default → handleUnknownEvent()
+
+5. Return response
+ └── 200 OK (even for skipped events)
+```
+
+### Invoice Settlement Handler
+
+```php
+protected function handleSettled(array $event): Response
+{
+ // 1. Find order by gateway session ID
+ $order = Order::where('gateway', 'btcpay')
+ ->where('gateway_session_id', $event['id'])
+ ->first();
+
+ // 2. Skip if already paid (idempotency)
+ if ($order->isPaid()) {
+ return response('Already processed', 200);
+ }
+
+ // 3. Create payment record
+ $payment = Payment::create([
+ 'gateway' => 'btcpay',
+ 'gateway_payment_id' => $event['id'],
+ 'amount' => $order->total,
+ 'status' => 'succeeded',
+ // ...
+ ]);
+
+ // 4. Fulfil order (provisions entitlements, creates invoice)
+ $this->commerce->fulfillOrder($order, $payment);
+
+ // 5. Send confirmation email
+ $this->sendOrderConfirmation($order);
+
+ return response('OK', 200);
+}
+```
+
+## Stripe Webhooks
+
+### Configuration
+
+In Stripe Dashboard:
+1. Navigate to Developers > Webhooks
+2. Add endpoint: `https://yourdomain.com/api/webhooks/stripe`
+3. Select events to listen for
+4. Copy signing secret to `STRIPE_WEBHOOK_SECRET`
+
+### Event Types
+
+| Stripe Event | Action |
+|--------------|--------|
+| `checkout.session.completed` | Fulfil order, create subscription |
+| `invoice.paid` | Renew subscription period |
+| `invoice.payment_failed` | Mark past_due, trigger dunning |
+| `customer.subscription.created` | Fallback (usually handled by checkout) |
+| `customer.subscription.updated` | Sync status, period dates |
+| `customer.subscription.deleted` | Cancel, revoke entitlements |
+| `payment_method.attached` | Store payment method |
+| `payment_method.detached` | Deactivate payment method |
+| `payment_method.updated` | Update card details |
+| `setup_intent.succeeded` | Attach payment method from setup flow |
+
+### Checkout Completion Handler
+
+```php
+protected function handleCheckoutCompleted(array $event): Response
+{
+ $session = $event['raw']['data']['object'];
+ $orderId = $session['metadata']['order_id'];
+
+ // Find and validate order
+ $order = Order::find($orderId);
+ if (!$order || $order->isPaid()) {
+ return response('Already processed', 200);
+ }
+
+ // Create payment record
+ $payment = Payment::create([
+ 'gateway' => 'stripe',
+ 'gateway_payment_id' => $session['payment_intent'],
+ 'amount' => $session['amount_total'] / 100,
+ 'status' => 'succeeded',
+ ]);
+
+ // Handle subscription if present
+ if (!empty($session['subscription'])) {
+ $this->createOrUpdateSubscriptionFromSession($order, $session);
+ }
+
+ // Fulfil order
+ $this->commerce->fulfillOrder($order, $payment);
+
+ return response('OK', 200);
+}
+```
+
+### Subscription Invoice Handler
+
+```php
+protected function handleInvoicePaid(array $event): Response
+{
+ $invoice = $event['raw']['data']['object'];
+ $subscriptionId = $invoice['subscription'];
+
+ // Find subscription
+ $subscription = Subscription::where('gateway', 'stripe')
+ ->where('gateway_subscription_id', $subscriptionId)
+ ->first();
+
+ // Update period dates
+ $subscription->renew(
+ Carbon::createFromTimestamp($invoice['period_start']),
+ Carbon::createFromTimestamp($invoice['period_end'])
+ );
+
+ // Create payment record
+ $payment = Payment::create([...]);
+
+ // Create local invoice
+ $this->invoiceService->createForRenewal($subscription->workspace, ...);
+
+ return response('OK', 200);
+}
+```
+
+## Signature Verification
+
+### BTCPay
+
+```php
+// BTCPayGateway::verifyWebhookSignature()
+
+$providedSignature = $signature;
+if (str_starts_with($signature, 'sha256=')) {
+ $providedSignature = substr($signature, 7);
+}
+
+$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
+
+return hash_equals($expectedSignature, $providedSignature);
+```
+
+### Stripe
+
+```php
+// StripeGateway::verifyWebhookSignature()
+
+try {
+ \Stripe\Webhook::constructEvent($payload, $signature, $this->webhookSecret);
+ return true;
+} catch (\Exception $e) {
+ return false;
+}
+```
+
+## Webhook Logging
+
+All webhook events are logged via `WebhookLogger`:
+
+```php
+// Start logging
+$this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
+
+// Link to entities for audit trail
+$this->webhookLogger->linkOrder($order);
+$this->webhookLogger->linkSubscription($subscription);
+
+// Mark outcome
+$this->webhookLogger->success($response);
+$this->webhookLogger->fail($errorMessage, $statusCode);
+$this->webhookLogger->skip($reason);
+```
+
+Logged data includes:
+- Event type and ID
+- Raw payload (encrypted)
+- IP address and user agent
+- Processing outcome
+- Related order/subscription IDs
+
+## Error Handling
+
+### Gateway Retries
+
+Both gateways retry failed webhooks:
+
+| Gateway | Retry Schedule | Max Attempts |
+|---------|---------------|--------------|
+| BTCPay | Exponential backoff | Configurable |
+| Stripe | Exponential over 3 days | ~20 attempts |
+
+**Important:** Return `200 OK` even for events that are skipped or already processed. Only return `500` for actual processing errors that should be retried.
+
+### Transaction Safety
+
+All webhook handlers wrap processing in database transactions:
+
+```php
+try {
+ $response = DB::transaction(function () use ($event) {
+ return match ($event['type']) {
+ 'invoice.paid' => $this->handleSettled($event),
+ // ...
+ };
+ });
+ return $response;
+} catch (\Exception $e) {
+ Log::error('Webhook processing error', [...]);
+ return response('Processing error', 500);
+}
+```
+
+## Testing Webhooks
+
+### Local Development
+
+Use gateway CLI tools to send test webhooks:
+
+**BTCPay:**
+```bash
+# Trigger test webhook from BTCPay admin
+# Or use btcpay-cli if available
+```
+
+**Stripe:**
+```bash
+# Forward webhooks to local
+stripe listen --forward-to localhost:8000/api/webhooks/stripe
+
+# Trigger specific event
+stripe trigger checkout.session.completed
+```
+
+### Automated Tests
+
+See `tests/Feature/WebhookTest.php` for webhook handler tests:
+
+```php
+test('btcpay settled webhook fulfils order', function () {
+ $order = Order::factory()->create(['status' => 'processing']);
+
+ $payload = json_encode([
+ 'type' => 'InvoiceSettled',
+ 'invoiceId' => $order->gateway_session_id,
+ // ...
+ ]);
+
+ $signature = hash_hmac('sha256', $payload, config('commerce.gateways.btcpay.webhook_secret'));
+
+ $response = $this->postJson('/api/webhooks/btcpay', [], [
+ 'BTCPay-Sig' => $signature,
+ 'Content-Type' => 'application/json',
+ ]);
+
+ $response->assertStatus(200);
+ expect($order->fresh()->status)->toBe('paid');
+});
+```
+
+## Troubleshooting
+
+### Common Issues
+
+**401 Invalid Signature**
+- Check webhook secret matches environment variable
+- Ensure raw payload is used (not parsed JSON)
+- Verify signature header name is correct
+
+**Order Not Found**
+- Check `gateway_session_id` matches invoice ID
+- Verify order was created before webhook arrived
+- Check for typos in metadata passed to gateway
+
+**Duplicate Processing**
+- Normal behavior if webhook is retried
+- Order state check (`isPaid()`) prevents double fulfillment
+- Consider adding idempotency key storage
+
+### Debug Logging
+
+Enable verbose logging temporarily:
+
+```php
+// In webhook controller
+Log::debug('Webhook payload', [
+ 'type' => $event['type'],
+ 'id' => $event['id'],
+ 'raw' => $event['raw'],
+]);
+```
+
+### Webhook Event Viewer
+
+Query logged events:
+
+```sql
+SELECT * FROM commerce_webhook_events
+WHERE event_type = 'InvoiceSettled'
+ AND status = 'failed'
+ORDER BY created_at DESC
+LIMIT 10;
+```
diff --git a/docs/packages/content/architecture.md b/docs/packages/content/architecture.md
new file mode 100644
index 0000000..405652d
--- /dev/null
+++ b/docs/packages/content/architecture.md
@@ -0,0 +1,422 @@
+---
+title: Architecture
+description: Technical architecture of the core-content package
+updated: 2026-01-29
+---
+
+# Architecture
+
+The `core-content` package provides headless CMS functionality for the Host UK platform. It handles content management, AI-powered generation, revision history, webhooks for external CMS integration, and search capabilities.
+
+## Package Overview
+
+**Namespace:** `Core\Mod\Content\`
+**Entry Point:** `Boot.php` (Laravel Service Provider)
+**Dependencies:**
+- `core-php` (Foundation framework, events)
+- `core-tenant` (Workspaces, users, entitlements)
+- Optional: `core-agentic` (AI services for content generation)
+- Optional: `core-mcp` (MCP tool handlers)
+
+## Directory Structure
+
+```
+core-content/
+├── Boot.php # Service provider with event listeners
+├── config.php # Package configuration
+├── Models/ # Eloquent models (10 models)
+├── Services/ # Business logic services
+├── Controllers/ # API and web controllers
+│ └── Api/ # REST API controllers
+├── Jobs/ # Queue jobs
+├── Mcp/ # MCP tool handlers
+│ └── Handlers/ # Individual MCP tools
+├── Concerns/ # Traits
+├── Console/ # Artisan commands
+│ └── Commands/ # Command implementations
+├── Enums/ # PHP enums
+├── Migrations/ # Database migrations
+├── Observers/ # Model observers
+├── routes/ # Route definitions
+├── View/ # Livewire components and Blade views
+│ ├── Modal/ # Livewire components
+│ └── Blade/ # Blade templates
+├── tests/ # Test suite
+└── docs/ # Documentation
+```
+
+## Core Concepts
+
+### Content Items
+
+The primary content model. Supports multiple content types and sources:
+
+```php
+// Content types (where content originates)
+enum ContentType: string {
+ case NATIVE = 'native'; // Created in Host Hub editor
+ case HOSTUK = 'hostuk'; // Alias for native (backwards compat)
+ case SATELLITE = 'satellite'; // Per-service content
+ case WORDPRESS = 'wordpress'; // Legacy synced content
+}
+```
+
+Content items belong to workspaces and have:
+- Title, slug, excerpt, content (HTML/Markdown/JSON)
+- Status (draft, publish, future, private, pending)
+- Author and last editor tracking
+- Revision history
+- Taxonomy (categories, tags)
+- SEO metadata
+- Preview tokens for sharing unpublished content
+- CDN cache invalidation tracking
+
+### Content Briefs
+
+Briefs drive AI-powered content generation. They define what content to create:
+
+```php
+// Brief content types (what to generate)
+enum BriefContentType: string {
+ case HELP_ARTICLE = 'help_article'; // Documentation
+ case BLOG_POST = 'blog_post'; // Blog articles
+ case LANDING_PAGE = 'landing_page'; // Marketing pages
+ case SOCIAL_POST = 'social_post'; // Social media
+}
+```
+
+Brief workflow: `pending` -> `queued` -> `generating` -> `review` -> `published`
+
+### Revisions
+
+Every content change creates an immutable revision snapshot. Revisions support:
+- Change type tracking (edit, autosave, restore, publish)
+- Word/character count tracking
+- Side-by-side diff comparison with LCS algorithm
+- Configurable retention policies (max count, max age)
+
+## Service Layer
+
+### AIGatewayService
+
+Orchestrates two-stage AI content generation:
+
+1. **Stage 1: Draft (Gemini)** - Fast, cost-effective initial generation
+2. **Stage 2: Refine (Claude)** - Quality refinement and brand voice alignment
+
+```php
+$gateway = app(AIGatewayService::class);
+
+// Two-stage pipeline
+$result = $gateway->generateAndRefine($brief);
+
+// Or individual stages
+$draft = $gateway->generateDraft($brief);
+$refined = $gateway->refineDraft($brief, $draftContent);
+
+// Direct Claude generation (skip Gemini)
+$content = $gateway->generateDirect($brief);
+```
+
+### ContentSearchService
+
+Full-text search with multiple backend support:
+
+```php
+// Backends (configured via CONTENT_SEARCH_BACKEND)
+const BACKEND_DATABASE = 'database'; // LIKE queries with relevance
+const BACKEND_SCOUT_DATABASE = 'scout_database'; // Laravel Scout
+const BACKEND_MEILISEARCH = 'meilisearch'; // Laravel Scout + Meilisearch
+```
+
+Features:
+- Relevance scoring (title > slug > excerpt > content)
+- Filters: type, status, category, tag, date range, content_type
+- Autocomplete suggestions
+- Re-indexing support for Scout backends
+
+### WebhookRetryService
+
+Handles failed webhook processing with exponential backoff:
+
+```
+Retry intervals: 1m, 5m, 15m, 1h, 4h
+Max retries: 5 (configurable per webhook)
+```
+
+### ContentRender
+
+Public-facing content renderer with caching:
+- Homepage, blog listing, post, page rendering
+- Cache TTL: 1 hour production, 1 minute development
+- Cache key sanitisation for special characters
+
+### CdnPurgeService
+
+CDN cache invalidation via Bunny CDN:
+- Triggered by ContentItemObserver on publish/update
+- URL-based and tag-based purging
+- Workspace-level cache clearing
+
+## Event-Driven Architecture
+
+The package uses the event-driven module loading pattern from `core-php`:
+
+```php
+class Boot extends ServiceProvider
+{
+ public static array $listens = [
+ WebRoutesRegistering::class => 'onWebRoutes',
+ ApiRoutesRegistering::class => 'onApiRoutes',
+ ConsoleBooting::class => 'onConsole',
+ McpToolsRegistering::class => 'onMcpTools',
+ ];
+}
+```
+
+Handlers register:
+- **Web Routes:** Public blog, help pages, content preview
+- **API Routes:** REST API for briefs, media, search, generation
+- **Console:** Artisan commands for scheduling, pruning
+- **MCP Tools:** AI agent content management tools
+
+## API Structure
+
+### Authenticated Endpoints (Session or API Key)
+
+```
+# Content Briefs
+GET /api/content/briefs # List briefs
+POST /api/content/briefs # Create brief
+GET /api/content/briefs/{id} # Get brief
+PUT /api/content/briefs/{id} # Update brief
+DELETE /api/content/briefs/{id} # Delete brief
+POST /api/content/briefs/bulk # Bulk create
+GET /api/content/briefs/next # Next ready for processing
+
+# AI Generation (rate limited: 10/min)
+POST /api/content/generate/draft # Generate draft (Gemini)
+POST /api/content/generate/refine # Refine draft (Claude)
+POST /api/content/generate/full # Full pipeline
+POST /api/content/generate/social # Social posts from content
+
+# Content Search (rate limited: 60/min)
+GET /api/content/search # Full-text search
+GET /api/content/search/suggest # Autocomplete
+GET /api/content/search/info # Backend info
+POST /api/content/search/reindex # Trigger re-index
+
+# Revisions
+GET /api/content/items/{id}/revisions # List revisions
+GET /api/content/revisions/{id} # Get revision
+POST /api/content/revisions/{id}/restore # Restore revision
+GET /api/content/revisions/{id}/compare/{other} # Compare
+
+# Preview
+POST /api/content/items/{id}/preview/generate # Generate preview link
+DELETE /api/content/items/{id}/preview/revoke # Revoke preview link
+```
+
+### Public Endpoints
+
+```
+# Webhooks (signature verified, no auth)
+POST /api/content/webhooks/{endpoint} # Receive external webhooks
+
+# Web Routes
+GET /blog # Blog listing
+GET /blog/{slug} # Blog post
+GET /help # Help centre
+GET /help/{slug} # Help article
+GET /content/preview/{id} # Preview content
+```
+
+## Rate Limiting
+
+Defined in `Boot::configureRateLimiting()`:
+
+| Limiter | Authenticated | Unauthenticated |
+|---------|---------------|-----------------|
+| `content-generate` | 10/min per user/workspace | 2/min per IP |
+| `content-briefs` | 30/min per user | 5/min per IP |
+| `content-webhooks` | 60/min per endpoint | 30/min per IP |
+| `content-search` | Configurable (default 60/min) | 20/min per IP |
+
+## MCP Tools
+
+Seven MCP tools for AI agent integration:
+
+| Tool | Description |
+|------|-------------|
+| `content_list` | List content items with filters |
+| `content_read` | Read content by ID or slug |
+| `content_search` | Full-text search |
+| `content_create` | Create new content |
+| `content_update` | Update existing content |
+| `content_delete` | Soft delete content |
+| `content_taxonomies` | List categories and tags |
+
+All tools:
+- Require workspace resolution
+- Check entitlements (`content.mcp_access`, `content.items`)
+- Log actions to MCP session
+- Return structured responses
+
+## Data Flow
+
+### Content Creation via MCP
+
+```
+Agent Request
+ ↓
+ContentCreateHandler::handle()
+ ↓
+resolveWorkspace() → Workspace model
+ ↓
+checkEntitlement() → EntitlementService
+ ↓
+ContentItem::create()
+ ↓
+createRevision() → ContentRevision
+ ↓
+recordUsage() → EntitlementService
+ ↓
+Response with content ID
+```
+
+### Webhook Processing
+
+```
+External CMS
+ ↓
+POST /api/content/webhooks/{endpoint}
+ ↓
+ContentWebhookController::receive()
+ ↓
+Verify signature → ContentWebhookEndpoint::verifySignature()
+ ↓
+Check type allowed → ContentWebhookEndpoint::isTypeAllowed()
+ ↓
+Create ContentWebhookLog
+ ↓
+Dispatch ProcessContentWebhook job
+ ↓
+Job::handle()
+ ↓
+Process based on event type (wordpress.*, cms.*, generic.*)
+ ↓
+Create/Update/Delete ContentItem
+ ↓
+Mark log completed
+```
+
+### AI Generation Pipeline
+
+```
+ContentBrief
+ ↓
+GenerateContentJob dispatched
+ ↓
+Stage 1: AIGatewayService::generateDraft()
+ ↓
+GeminiService::generate() → Draft content
+ ↓
+Brief::markDraftComplete()
+ ↓
+Stage 2: AIGatewayService::refineDraft()
+ ↓
+ClaudeService::generate() → Refined content
+ ↓
+Brief::markRefined()
+ ↓
+AIUsage records created for each stage
+```
+
+## Configuration
+
+Key settings in `config.php`:
+
+```php
+return [
+ 'generation' => [
+ 'default_timeout' => env('CONTENT_GENERATION_TIMEOUT', 300),
+ 'timeouts' => [
+ 'help_article' => 180,
+ 'blog_post' => 240,
+ 'landing_page' => 300,
+ 'social_post' => 60,
+ ],
+ 'max_retries' => 3,
+ 'backoff' => [30, 60, 120],
+ ],
+ 'revisions' => [
+ 'max_per_item' => env('CONTENT_MAX_REVISIONS', 50),
+ 'max_age_days' => 180,
+ 'preserve_published' => true,
+ ],
+ 'cache' => [
+ 'ttl' => env('CONTENT_CACHE_TTL', 3600),
+ 'prefix' => 'content:render',
+ ],
+ 'search' => [
+ 'backend' => env('CONTENT_SEARCH_BACKEND', 'database'),
+ 'min_query_length' => 2,
+ 'max_per_page' => 50,
+ 'default_per_page' => 20,
+ 'rate_limit' => 60,
+ ],
+];
+```
+
+## Database Schema
+
+### Primary Tables
+
+| Table | Purpose |
+|-------|---------|
+| `content_items` | Content storage (posts, pages) |
+| `content_revisions` | Version history |
+| `content_taxonomies` | Categories and tags |
+| `content_item_taxonomy` | Pivot table |
+| `content_media` | Media attachments |
+| `content_authors` | Author profiles |
+| `content_briefs` | AI generation briefs |
+| `content_tasks` | Scheduled content tasks |
+| `content_webhook_endpoints` | Webhook configurations |
+| `content_webhook_logs` | Webhook processing logs |
+| `ai_usage` | AI API usage tracking |
+| `prompts` | AI prompt templates |
+| `prompt_versions` | Prompt version history |
+
+### Key Indexes
+
+- `content_items`: Composite indexes on `(workspace_id, slug, type)`, `(workspace_id, status, type)`, `(workspace_id, status, content_type)`
+- `content_revisions`: Index on `(content_item_id, revision_number)`
+- `content_webhook_logs`: Index on `(workspace_id, status)`, `(status, created_at)`
+
+## Extension Points
+
+### Adding New Content Types
+
+1. Add value to `ContentType` enum
+2. Update `ContentType::isNative()` if applicable
+3. Add any type-specific scopes to `ContentItem`
+
+### Adding New AI Generation Types
+
+1. Add value to `BriefContentType` enum
+2. Add timeout to `config.php` generation.timeouts
+3. Add prompt in `AIGatewayService::getDraftSystemPrompt()`
+
+### Adding New Webhook Event Types
+
+1. Add to `ContentWebhookEndpoint::ALLOWED_TYPES`
+2. Add handler in `ProcessContentWebhook::processWordPress()` or `processCms()`
+3. Add event type mapping in `ContentWebhookController::normaliseEventType()`
+
+### Adding New MCP Tools
+
+1. Create handler in `Mcp/Handlers/` implementing `McpToolHandler`
+2. Define `schema()` with tool name, description, input schema
+3. Implement `handle()` with workspace resolution and entitlement checks
+4. Register in `Boot::onMcpTools()`
diff --git a/docs/packages/content/index.md b/docs/packages/content/index.md
new file mode 100644
index 0000000..6495bbf
--- /dev/null
+++ b/docs/packages/content/index.md
@@ -0,0 +1,39 @@
+---
+title: Content Package
+navTitle: Content
+navOrder: 40
+---
+
+# Content Package
+
+Headless CMS functionality for the Host UK platform.
+
+## Overview
+
+The `core-content` package provides content management, AI-powered generation, revision history, webhooks for external CMS integration, and search capabilities.
+
+## Features
+
+- **Content items** - Flexible content types with custom fields
+- **AI generation** - Content creation via AI services
+- **Revision history** - Full version control for content
+- **Webhooks** - External CMS integration
+- **Search** - Full-text search across content
+
+## Installation
+
+```bash
+composer require host-uk/core-content
+```
+
+## Dependencies
+
+- `core-php` - Foundation framework
+- `core-tenant` - Workspaces and users
+- Optional: `core-agentic` - AI content generation
+- Optional: `core-mcp` - MCP tool handlers
+
+## Documentation
+
+- [Architecture](./architecture) - Technical architecture
+- [Security](./security) - Content security model
\ No newline at end of file
diff --git a/docs/packages/content/security.md b/docs/packages/content/security.md
new file mode 100644
index 0000000..0b3d220
--- /dev/null
+++ b/docs/packages/content/security.md
@@ -0,0 +1,389 @@
+---
+title: Security
+description: Security considerations and audit notes for core-content
+updated: 2026-01-29
+---
+
+# Security
+
+This document covers security considerations, known risks, and recommended mitigations for the `core-content` package.
+
+## Authentication and Authorisation
+
+### API Authentication
+
+The content API supports two authentication methods:
+
+1. **Session Authentication** (`auth` middleware)
+ - For browser-based access
+ - CSRF protection via Laravel's standard middleware
+
+2. **API Key Authentication** (`api.auth` middleware)
+ - For programmatic access
+ - Keys prefixed with `hk_`
+ - Scope enforcement via `api.scope.enforce` middleware
+
+### Webhook Authentication
+
+Webhooks use HMAC signature verification instead of session/API key auth:
+
+```php
+// Signature verification in ContentWebhookEndpoint
+public function verifySignature(string $payload, ?string $signature): bool
+{
+ $expectedSignature = hash_hmac('sha256', $payload, $this->secret);
+ return hash_equals($expectedSignature, $signature);
+}
+```
+
+**Supported signature headers:**
+- `X-Signature`
+- `X-Hub-Signature-256` (GitHub format)
+- `X-WP-Webhook-Signature` (WordPress format)
+- `X-Content-Signature`
+- `Signature`
+
+### MCP Tool Authentication
+
+MCP tools authenticate via the MCP session context. Workspace access is verified through:
+- Workspace resolution (by slug or ID)
+- Entitlement checks (`content.mcp_access`, `content.items`)
+
+## Known Security Considerations
+
+### HIGH: HTML Sanitisation Fallback
+
+**Location:** `Models/ContentItem.php:333-351`
+
+**Issue:** The `getSanitisedContent()` method falls back to `strip_tags()` if HTMLPurifier is unavailable. This is insufficient for XSS protection.
+
+```php
+// Current fallback (insufficient)
+$allowedTags = '......';
+return strip_tags($content, $allowedTags);
+```
+
+**Risk:** XSS attacks via crafted HTML in content body.
+
+**Mitigation:**
+1. Ensure HTMLPurifier is installed in production
+2. Add package check in boot to fail loudly if missing
+3. Consider using `voku/anti-xss` as a lighter alternative
+
+### HIGH: Webhook Signature Optional
+
+**Location:** `Models/ContentWebhookEndpoint.php:205-210`
+
+**Issue:** When no secret is configured, signature verification is skipped:
+
+```php
+if (empty($this->secret)) {
+ return true; // Accepts all requests
+}
+```
+
+**Risk:** Unauthenticated webhook injection if endpoint has no secret.
+
+**Mitigation:**
+1. Require secrets for all production endpoints
+2. Add explicit `allow_unsigned` flag if intentional
+3. Log warning when unsigned webhooks are accepted
+4. Rate limit unsigned endpoints more aggressively
+
+### MEDIUM: Workspace Access in MCP Handlers
+
+**Location:** `Mcp/Handlers/*.php`
+
+**Issue:** Workspace resolution allows lookup by ID:
+
+```php
+return Workspace::where('slug', $slug)
+ ->orWhere('id', $slug)
+ ->first();
+```
+
+**Risk:** If an attacker knows a workspace ID, they could potentially access content without being a workspace member.
+
+**Mitigation:**
+1. Always verify workspace membership after resolution
+2. Use entitlement checks (already present but verify coverage)
+3. Consider removing ID-based lookup for MCP
+
+### MEDIUM: Preview Token Enumeration
+
+**Location:** `Controllers/ContentPreviewController.php`
+
+**Issue:** No rate limiting on preview token generation endpoint. An attacker could probe for valid content IDs.
+
+**Mitigation:**
+1. Add rate limiting (30/min per user)
+2. Use constant-time responses regardless of content existence
+3. Consider using UUIDs instead of sequential IDs for preview URLs
+
+### LOW: Webhook Payload Content Types
+
+**Location:** `Jobs/ProcessContentWebhook.php:288-289`
+
+**Issue:** Content type from external webhook is assigned directly:
+
+```php
+$contentItem->content_type = ContentType::NATIVE;
+```
+
+**Risk:** External systems could potentially inject invalid content types.
+
+**Mitigation:**
+1. Validate against `ContentType` enum
+2. Default to a safe type if validation fails
+3. Log invalid types for monitoring
+
+## Input Validation
+
+### API Request Validation
+
+All API controllers use Laravel's validation:
+
+```php
+$validated = $request->validate([
+ 'q' => 'required|string|min:2|max:500',
+ 'type' => 'nullable|string|in:post,page',
+ 'status' => 'nullable',
+ // ...
+]);
+```
+
+**Validated inputs:**
+- Search queries (min/max length, string type)
+- Content types (enum validation)
+- Pagination (min/max values)
+- Date ranges (date format, logical order)
+
+### MCP Input Validation
+
+MCP handlers validate via JSON schema:
+
+```php
+'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'workspace' => ['type' => 'string'],
+ 'title' => ['type' => 'string'],
+ 'type' => ['type' => 'string', 'enum' => ['post', 'page']],
+ ],
+ 'required' => ['workspace', 'title'],
+]
+```
+
+### Webhook Payload Validation
+
+Webhook payloads undergo:
+- JSON decode validation
+- Event type normalisation
+- Content ID extraction with fallbacks
+
+**Note:** Payload content is stored in JSON column without full validation. Processing logic handles missing/invalid fields gracefully.
+
+## Rate Limiting
+
+### Configured Limiters
+
+| Endpoint | Auth | Unauthenticated | Key |
+|----------|------|-----------------|-----|
+| AI Generation | 10/min | 2/min | `content-generate` |
+| Brief Creation | 30/min | 5/min | `content-briefs` |
+| Webhooks | 60/min | 30/min | `content-webhooks` |
+| Search | 60/min | 20/min | `content-search` |
+
+### Rate Limit Bypass Risks
+
+1. **IP Spoofing:** Ensure `X-Forwarded-For` handling is configured correctly
+2. **Workspace Switching:** Workspace-based limits should use user ID as fallback
+3. **API Key Sharing:** Each key should have independent limits
+
+## Data Protection
+
+### Sensitive Data Handling
+
+**Encrypted at rest:**
+- `ContentWebhookEndpoint.secret` (cast to `encrypted`)
+- `ContentWebhookEndpoint.previous_secret` (cast to `encrypted`)
+
+**Hidden from serialisation:**
+- Webhook secrets (via `$hidden` property)
+
+### PII Considerations
+
+Content may contain PII in:
+- Article body content
+- Author information
+- Webhook payloads
+
+**Recommendations:**
+1. Implement content retention policies
+2. Add GDPR data export/deletion support
+3. Log access to PII-containing content
+
+## Webhook Security
+
+### Circuit Breaker
+
+Endpoints automatically disable after 10 consecutive failures:
+
+```php
+const MAX_FAILURES = 10;
+
+public function incrementFailureCount(): void
+{
+ $this->increment('failure_count');
+ if ($this->failure_count >= self::MAX_FAILURES) {
+ $this->update(['is_enabled' => false]);
+ }
+}
+```
+
+### Secret Rotation
+
+Grace period support for secret rotation:
+
+```php
+public function isInGracePeriod(): bool
+{
+ // Accepts both current and previous secret during grace
+}
+```
+
+Default grace period: 24 hours
+
+### Allowed Event Types
+
+Endpoints can restrict which event types they accept:
+
+```php
+const ALLOWED_TYPES = [
+ 'wordpress.post_created',
+ 'wordpress.post_updated',
+ // ...
+ 'generic.payload',
+];
+```
+
+Wildcard support: `wordpress.*` matches all WordPress events.
+
+## Content Security
+
+### XSS Prevention
+
+1. **Input:** Content stored as-is to preserve formatting
+2. **Output:** `getSanitisedContent()` for public rendering
+3. **Admin:** Trusted content displayed with proper escaping
+
+**Blade template guidelines:**
+- Use `{{ $title }}` for plain text (auto-escaped)
+- Use `{!! $content !!}` only for sanitised HTML
+- Comments document which fields need which treatment
+
+### SQL Injection
+
+All database queries use:
+- Eloquent ORM (parameterised queries)
+- Query builder with bindings
+- No raw SQL with user input
+
+### CSRF Protection
+
+Web routes include CSRF middleware automatically. API routes exempt (use API key auth).
+
+## Audit Logging
+
+### Logged Events
+
+- Webhook receipt and processing
+- AI generation requests and results
+- Content creation/update/deletion via MCP
+- CDN cache purges
+- Authentication failures
+
+### Log Levels
+
+| Event | Level |
+|-------|-------|
+| Webhook signature failure | WARNING |
+| Circuit breaker triggered | WARNING |
+| Processing failure | ERROR |
+| Successful operations | INFO |
+| Skipped operations | DEBUG |
+
+## Recommendations
+
+### Immediate (P1)
+
+1. [ ] Require HTMLPurifier or equivalent in production
+2. [ ] Make webhook signature verification mandatory
+3. [ ] Add rate limiting to preview generation
+4. [ ] Validate content_type from webhook payloads
+
+### Short-term (P2)
+
+1. [ ] Add comprehensive audit logging
+2. [ ] Implement content access logging
+3. [ ] Add IP allowlisting option for webhooks
+4. [ ] Create security-focused test suite
+
+### Long-term (P3+)
+
+1. [ ] Implement content encryption at rest option
+2. [ ] Add GDPR compliance features
+3. [ ] Create security monitoring dashboard
+4. [ ] Add anomaly detection for webhook patterns
+
+## Security Testing
+
+### Manual Testing Checklist
+
+```
+[ ] Verify webhook signature rejection with invalid signature
+[ ] Test rate limiting enforcement
+[ ] Confirm XSS payloads are sanitised
+[ ] Verify workspace isolation in API responses
+[ ] Test preview token expiration
+[ ] Verify CSRF protection on web routes
+[ ] Test SQL injection attempts in search
+[ ] Verify file type validation on media uploads
+```
+
+### Automated Testing
+
+```bash
+# Run security-focused tests
+./vendor/bin/pest --filter=Security
+
+# Check for common vulnerabilities
+./vendor/bin/pint --test # Code style (includes some security patterns)
+```
+
+## Incident Response
+
+### Webhook Compromise
+
+1. Disable affected endpoint
+2. Rotate all secrets
+3. Review webhook logs for suspicious patterns
+4. Regenerate secrets for all endpoints
+
+### Content Injection
+
+1. Identify affected content items
+2. Restore from revision history
+3. Review webhook source
+4. Add additional validation
+
+### API Key Leak
+
+1. Revoke compromised key
+2. Review access logs
+3. Generate new key with reduced scope
+4. Monitor for unauthorised access
+
+## Contact
+
+Security issues should be reported to the security team. Do not create public issues for security vulnerabilities.
diff --git a/docs/packages/developer/architecture.md b/docs/packages/developer/architecture.md
new file mode 100644
index 0000000..b7539b8
--- /dev/null
+++ b/docs/packages/developer/architecture.md
@@ -0,0 +1,390 @@
+---
+title: Architecture
+description: Technical architecture of the core-developer package
+updated: 2026-01-29
+---
+
+# Architecture
+
+The `core-developer` package provides administrative developer tools for the Host UK platform. It is designed exclusively for "Hades" tier users (god-mode access) and includes debugging, monitoring, and server management capabilities.
+
+## Package Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Namespace | `Core\Developer\` |
+| Type | L1 Module (Laravel Package) |
+| Dependencies | `host-uk/core`, `host-uk/core-admin` |
+| PHP Version | 8.2+ |
+| Laravel Version | 11.x / 12.x |
+| Livewire Version | 3.x / 4.x |
+
+## Directory Structure
+
+```
+src/
+├── Boot.php # Service provider & event handlers
+├── Controllers/
+│ └── DevController.php # REST API endpoints
+├── Concerns/
+│ └── RemoteServerManager.php # SSH connection trait
+├── Console/Commands/
+│ └── CopyDeviceFrames.php # Asset management command
+├── Data/
+│ └── RouteTestResult.php # DTO for route test results
+├── Exceptions/
+│ └── SshConnectionException.php
+├── Lang/
+│ └── en_GB/developer.php # Translations
+├── Listeners/
+│ └── SetHadesCookie.php # Login event listener
+├── Middleware/
+│ ├── ApplyIconSettings.php # Icon preferences from cookies
+│ └── RequireHades.php # Authorization middleware
+├── Migrations/
+│ └── 0001_01_01_000001_create_developer_tables.php
+├── Models/
+│ └── Server.php # SSH server model
+├── Providers/
+│ ├── HorizonServiceProvider.php
+│ └── TelescopeServiceProvider.php
+├── Routes/
+│ └── admin.php # Route definitions
+├── Services/
+│ ├── LogReaderService.php # Log file parsing
+│ └── RouteTestService.php # Route testing logic
+├── Tests/
+│ └── UseCase/
+│ └── DevToolsBasic.php # Feature tests
+└── View/
+ ├── Blade/
+ │ └── admin/ # Blade templates
+ │ ├── activity-log.blade.php
+ │ ├── cache.blade.php
+ │ ├── database.blade.php
+ │ ├── logs.blade.php
+ │ ├── route-inspector.blade.php
+ │ ├── routes.blade.php
+ │ └── servers.blade.php
+ └── Modal/
+ └── Admin/ # Livewire components
+ ├── ActivityLog.php
+ ├── Cache.php
+ ├── Database.php
+ ├── Logs.php
+ ├── RouteInspector.php
+ ├── Routes.php
+ └── Servers.php
+```
+
+## Event-Driven Module Loading
+
+The module uses the Core Framework's event-driven lazy loading pattern. The `Boot` class declares which events it listens to:
+
+```php
+public static array $listens = [
+ AdminPanelBooting::class => 'onAdminPanel',
+ ConsoleBooting::class => 'onConsole',
+];
+```
+
+This ensures routes, views, and commands are only registered when the admin panel or console is actually used.
+
+### Lifecycle Events
+
+| Event | Handler | What Happens |
+|-------|---------|--------------|
+| `AdminPanelBooting` | `onAdminPanel()` | Registers views, routes, Pulse override |
+| `ConsoleBooting` | `onConsole()` | Registers Artisan commands |
+
+## Core Components
+
+### 1. Livewire Admin Pages
+
+All admin pages are full-page Livewire components using attribute-based configuration:
+
+```php
+#[Title('Application Logs')]
+#[Layout('hub::admin.layouts.app')]
+class Logs extends Component
+```
+
+Each component:
+- Checks Hades access in `mount()`
+- Uses `developer::admin.{name}` view namespace
+- Has corresponding Blade template in `View/Blade/admin/`
+
+### 2. API Controller
+
+`DevController` provides REST endpoints for:
+- `/hub/api/dev/logs` - Recent log entries
+- `/hub/api/dev/routes` - Route listing
+- `/hub/api/dev/session` - Session/request info
+- `/hub/api/dev/clear/{type}` - Cache clearing
+
+All endpoints are protected by `RequireHades` middleware and rate limiting.
+
+### 3. Services
+
+**LogReaderService**
+- Memory-efficient log reading (reads from end of file)
+- Parses Laravel log format
+- Automatic sensitive data redaction
+- Multi-log file support (daily/single channels)
+
+**RouteTestService**
+- Route discovery and formatting
+- Request building with parameters
+- In-process request execution
+- Response formatting and metrics
+
+### 4. RemoteServerManager Trait
+
+Provides SSH connection management for classes that need remote server access:
+
+```php
+class DeployApplication implements ShouldQueue
+{
+ use RemoteServerManager;
+
+ public function handle(): void
+ {
+ $this->withConnection($this->server, function () {
+ $this->run('cd /var/www && git pull');
+ });
+ }
+}
+```
+
+Key methods:
+- `connect()` / `disconnect()` - Connection lifecycle
+- `withConnection()` - Guaranteed cleanup pattern
+- `run()` / `runMany()` - Command execution
+- `fileExists()` / `readFile()` / `writeFile()` - File operations
+- `getDiskUsage()` / `getMemoryUsage()` - Server stats
+
+## Data Flow
+
+### Admin Page Request
+
+```
+Browser Request
+ ↓
+Laravel Router → /hub/dev/logs
+ ↓
+Livewire Component (Logs.php)
+ ↓
+mount() → checkHadesAccess()
+ ↓
+loadLogs() → LogReaderService
+ ↓
+render() → developer::admin.logs
+ ↓
+Response (HTML)
+```
+
+### API Request
+
+```
+Browser/JS Request
+ ↓
+Laravel Router → /hub/api/dev/logs
+ ↓
+RequireHades Middleware
+ ↓
+Rate Limiter (throttle:dev-logs)
+ ↓
+DevController::logs()
+ ↓
+LogReaderService
+ ↓
+Response (JSON)
+```
+
+### SSH Connection
+
+```
+Servers Component
+ ↓
+testConnection($serverId)
+ ↓
+Server::findOrFail()
+ ↓
+Write temp key file
+ ↓
+Process::run(['ssh', ...])
+ ↓
+Parse result
+ ↓
+Update server status
+ ↓
+Clean up temp file
+```
+
+## Database Schema
+
+### servers table
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | bigint | Primary key |
+| workspace_id | bigint | FK to workspaces |
+| name | varchar(128) | Display name |
+| ip | varchar(45) | IPv4/IPv6 address |
+| port | smallint | SSH port (default 22) |
+| user | varchar(64) | SSH username |
+| private_key | text | Encrypted SSH key |
+| status | varchar(32) | pending/connected/failed |
+| last_connected_at | timestamp | Last successful connection |
+| timestamps | | created_at, updated_at |
+| soft_deletes | | deleted_at |
+
+Indexes:
+- `workspace_id`
+- `(workspace_id, status)` composite
+
+## Admin Menu Structure
+
+The module registers a "Dev Tools" menu group with these items:
+
+```
+Dev Tools (admin group, priority 80)
+├── Logs → /hub/dev/logs
+├── Activity → /hub/dev/activity
+├── Servers → /hub/dev/servers
+├── Database → /hub/dev/database
+├── Routes → /hub/dev/routes
+├── Route Inspector → /hub/dev/route-inspector
+└── Cache → /hub/dev/cache
+```
+
+The menu is only visible to users with `admin` flag (Hades tier).
+
+## Rate Limiting
+
+API endpoints have rate limits configured in `Boot::configureRateLimiting()`:
+
+| Limiter | Limit | Purpose |
+|---------|-------|---------|
+| `dev-cache-clear` | 10/min | Prevent rapid cache clears |
+| `dev-logs` | 30/min | Log reading |
+| `dev-routes` | 30/min | Route listing |
+| `dev-session` | 60/min | Session info |
+
+Rate limits are per-user (or per-IP for unauthenticated requests).
+
+## Third-Party Integrations
+
+### Laravel Telescope
+
+Custom `TelescopeServiceProvider` configures:
+- Gate for Hades-only access in production
+- Entry filtering (errors, failed jobs in production)
+- Sensitive header/parameter hiding
+
+### Laravel Horizon
+
+Custom `HorizonServiceProvider` configures:
+- Gate for Hades-only access
+- Notification routing from config (email, SMS, Slack)
+
+### Laravel Pulse
+
+Custom Pulse dashboard view override at `View/Blade/vendor/pulse/dashboard.blade.php`.
+
+## Configuration
+
+The module expects these config keys (should be in `config/developer.php`):
+
+```php
+return [
+ // Hades cookie token
+ 'hades_token' => env('HADES_TOKEN'),
+
+ // SSH settings
+ 'ssh' => [
+ 'connection_timeout' => 30,
+ 'command_timeout' => 60,
+ ],
+
+ // Horizon notifications
+ 'horizon' => [
+ 'mail_to' => env('HORIZON_MAIL_TO'),
+ 'sms_to' => env('HORIZON_SMS_TO'),
+ 'slack_webhook' => env('HORIZON_SLACK_WEBHOOK'),
+ 'slack_channel' => env('HORIZON_SLACK_CHANNEL', '#alerts'),
+ ],
+];
+```
+
+## Extension Points
+
+### Adding New Admin Pages
+
+1. Create Livewire component in `View/Modal/Admin/`
+2. Create Blade view in `View/Blade/admin/`
+3. Add route in `Routes/admin.php`
+4. Add menu item in `Boot::adminMenuItems()`
+5. Add translations in `Lang/en_GB/developer.php`
+
+### Adding New API Endpoints
+
+1. Add method to `DevController`
+2. Add route in `Routes/admin.php` API group
+3. Create rate limiter in `Boot::configureRateLimiting()`
+4. Apply `throttle:limiter-name` middleware
+
+### Using RemoteServerManager
+
+```php
+use Core\Developer\Concerns\RemoteServerManager;
+
+class MyJob
+{
+ use RemoteServerManager;
+
+ public function handle(Server $server): void
+ {
+ $this->withConnection($server, function () {
+ // Commands executed on remote server
+ $result = $this->run('whoami');
+ // ...
+ });
+ }
+}
+```
+
+## Performance Considerations
+
+1. **Log Reading** - Uses backwards reading to avoid loading entire log into memory. Configurable `maxBytes` limit.
+
+2. **Route Caching** - Routes are computed once per request. The `RouteInspector` uses `#[Computed(cache: true)]` for route list.
+
+3. **Query Log** - Enabled only in local environment (`Boot::boot()`).
+
+4. **SSH Connections** - Always disconnect via `withConnection()` pattern to prevent resource leaks.
+
+## Dependencies
+
+### Composer Requirements
+
+- `host-uk/core` - Core framework
+- `host-uk/core-admin` - Admin panel infrastructure
+- `phpseclib3` - SSH connections (via RemoteServerManager)
+- `spatie/laravel-activitylog` - Activity logging
+
+### Frontend Dependencies
+
+- Flux UI components
+- Tailwind CSS
+- Livewire 3.x
+
+## Testing Strategy
+
+Tests use Pest syntax and focus on:
+- Page rendering and content
+- Authorization enforcement
+- API endpoint behaviour
+- Service logic
+
+Test database: SQLite in-memory with Telescope/Pulse disabled.
diff --git a/docs/packages/developer/index.md b/docs/packages/developer/index.md
new file mode 100644
index 0000000..f1a602c
--- /dev/null
+++ b/docs/packages/developer/index.md
@@ -0,0 +1,37 @@
+---
+title: Developer Package
+navTitle: Developer
+navOrder: 80
+---
+
+# Developer Package
+
+Administrative developer tools for the Host UK platform.
+
+## Overview
+
+The `core-developer` package provides debugging, monitoring, and server management capabilities. It is designed exclusively for "Hades" tier users (god-mode access).
+
+## Features
+
+- **Server management** - SSH connections and remote commands
+- **Route testing** - Automated route health checks
+- **Debug tools** - Development debugging utilities
+- **Horizon integration** - Queue monitoring
+- **Telescope integration** - Request debugging
+
+## Installation
+
+```bash
+composer require host-uk/core-developer
+```
+
+## Requirements
+
+- Hades tier access (god-mode)
+- `core-php` and `core-admin` packages
+
+## Documentation
+
+- [Architecture](./architecture) - Technical architecture
+- [Security](./security) - Access control and authorisation
\ No newline at end of file
diff --git a/docs/packages/developer/security.md b/docs/packages/developer/security.md
new file mode 100644
index 0000000..ae737f6
--- /dev/null
+++ b/docs/packages/developer/security.md
@@ -0,0 +1,269 @@
+---
+title: Security
+description: Security considerations and audit notes for core-developer
+updated: 2026-01-29
+---
+
+# Security Considerations
+
+The `core-developer` package provides powerful administrative capabilities that require careful security controls. This document outlines the security model, known risks, and mitigation strategies.
+
+## Threat Model
+
+### Assets Protected
+
+1. **Application logs** - May contain tokens, passwords, PII in error messages
+2. **Database access** - Read-only query execution against production data
+3. **SSH keys** - Encrypted private keys for server connections
+4. **Cache data** - Application cache, session data, config cache
+5. **Route information** - Full application route structure
+
+### Threat Actors
+
+1. **Unauthorized users** - Non-Hades users attempting to access dev tools
+2. **Compromised Hades account** - Attacker with valid Hades credentials
+3. **SSRF/Injection** - Attacker manipulating dev tools to access internal resources
+4. **Data exfiltration** - Extracting sensitive data via dev tools
+
+## Authorization Model
+
+### Hades Tier Requirement
+
+All developer tools require "Hades" access, verified via the `isHades()` method on the User model. This is enforced at multiple layers:
+
+| Layer | Implementation | File |
+|-------|----------------|------|
+| Middleware | `RequireHades::handle()` | `src/Middleware/RequireHades.php` |
+| Component | `checkHadesAccess()` in `mount()` | All Livewire components |
+| API | Controller `authorize()` calls | `src/Controllers/DevController.php` |
+| Menu | `admin` flag filtering | `src/Boot.php` |
+
+### Defence in Depth
+
+The authorization is intentionally redundant:
+- API routes use `RequireHades` middleware
+- Livewire components check in `mount()`
+- Some controller methods call `$this->authorize()`
+
+This ensures access is blocked even if one layer fails.
+
+### Known Issue: Test Environment
+
+Tests currently pass without setting Hades tier on the test user. This suggests authorization may not be properly enforced in the test environment. See TODO.md for remediation.
+
+## Data Protection
+
+### Log Redaction
+
+The `LogReaderService` automatically redacts sensitive patterns before displaying logs:
+
+| Pattern | Replacement |
+|---------|-------------|
+| Stripe API keys | `[STRIPE_KEY_REDACTED]` |
+| GitHub tokens | `[GITHUB_TOKEN_REDACTED]` |
+| Bearer tokens | `Bearer [TOKEN_REDACTED]` |
+| API keys/secrets | `[KEY_REDACTED]` / `[REDACTED]` |
+| AWS credentials | `[AWS_KEY_REDACTED]` / `[AWS_SECRET_REDACTED]` |
+| Database URLs | Connection strings with `[USER]:[PASS]` |
+| Email addresses | Partial: `jo***@example.com` |
+| IP addresses | Partial: `192.168.xxx.xxx` |
+| Credit card numbers | `[CARD_REDACTED]` |
+| JWT tokens | `[JWT_REDACTED]` |
+| Private keys | `[PRIVATE_KEY_REDACTED]` |
+
+**Limitation**: Patterns are regex-based and may not catch all sensitive data. Custom application secrets with non-standard formats will not be redacted.
+
+### SSH Key Storage
+
+Server private keys are:
+- Encrypted at rest using Laravel's `encrypted` cast
+- Hidden from serialization (`$hidden` array)
+- Never exposed in API responses or views
+- Stored in `text` column (supports long keys)
+
+### Database Query Tool
+
+The database query component restricts access to read-only operations:
+
+```php
+protected const ALLOWED_STATEMENTS = ['SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN'];
+```
+
+**Known Risk**: The current implementation only checks the first word, which does not prevent:
+- Stacked queries: `SELECT 1; DROP TABLE users`
+- Subqueries with side effects (MySQL stored procedures)
+
+**Mitigation**: Use a proper SQL parser or prevent semicolons entirely.
+
+### Session Data Exposure
+
+The `/hub/api/dev/session` endpoint exposes:
+- Session ID
+- User IP address
+- User agent (truncated to 100 chars)
+- Request method and URL
+
+This is intentional for debugging but could be abused for session hijacking if credentials are compromised.
+
+## Rate Limiting
+
+All API endpoints have rate limits to prevent abuse:
+
+| Endpoint | Limit | Rationale |
+|----------|-------|-----------|
+| Cache clear | 10/min | Prevent DoS via rapid cache invalidation |
+| Log reading | 30/min | Limit log scraping |
+| Route listing | 30/min | Prevent enumeration attacks |
+| Session info | 60/min | Higher limit for debugging workflows |
+
+Rate limits are per-user (authenticated) or per-IP (unauthenticated).
+
+## SSH Connection Security
+
+### Key Handling
+
+The `testConnection()` method in `Servers.php` creates a temporary key file:
+
+```php
+$tempKeyPath = sys_get_temp_dir().'/ssh_test_'.uniqid();
+file_put_contents($tempKeyPath, $server->getDecryptedPrivateKey());
+chmod($tempKeyPath, 0600);
+```
+
+**Risk**: Predictable filename pattern and race condition window between write and use.
+
+**Recommendation**: Use `tempnam()` for unique filename, write with restrictive umask.
+
+### Connection Validation
+
+- `StrictHostKeyChecking=no` is used for convenience but prevents MITM detection
+- `BatchMode=yes` prevents interactive prompts
+- `ConnectTimeout=10` limits hanging connections
+
+### Workspace Isolation
+
+The `RemoteServerManager::connect()` method validates workspace ownership before connecting:
+
+```php
+if (! $server->belongsToCurrentWorkspace()) {
+ throw new SshConnectionException('Unauthorised access to server.', $server->name);
+}
+```
+
+This prevents cross-tenant server access.
+
+## Route Testing Security
+
+### Environment Restriction
+
+Route testing is only available in `local` and `testing` environments:
+
+```php
+public function isTestingAllowed(): bool
+{
+ return App::environment(['local', 'testing']);
+}
+```
+
+This prevents accidental data modification in production.
+
+### Destructive Operation Warnings
+
+Routes using `DELETE`, `PUT`, `PATCH`, `POST` methods are marked as destructive and show warnings in the UI.
+
+### CSRF Consideration
+
+Test requests bypass CSRF as they are internal requests. The `X-Requested-With: XMLHttpRequest` header is set by default.
+
+## Cookie Security
+
+### Hades Cookie
+
+The `SetHadesCookie` listener sets a cookie on login:
+
+| Attribute | Value | Purpose |
+|-----------|-------|---------|
+| Value | Encrypted token | Validates Hades status |
+| Duration | 1 year | Long-lived for convenience |
+| HttpOnly | true | Prevents XSS access |
+| Secure | true (production) | HTTPS only in production |
+| SameSite | lax | CSRF protection |
+
+### Icon Settings Cookie
+
+`ApplyIconSettings` middleware reads `icon-style` and `icon-size` cookies set by JavaScript. These are stored in session for Blade component access.
+
+**Risk**: Cookie values are user-controlled. Ensure they are properly escaped in views.
+
+## Audit Logging
+
+### Logged Actions
+
+| Action | What's Logged |
+|--------|---------------|
+| Log clear | user_id, email, previous_size_bytes, IP |
+| Database query | user_id, email, query, row_count, execution_time, IP |
+| Blocked query | user_id, email, query (attempted), IP |
+| Route test | user_id, route, method, IP |
+| Server failure | Server ID, failure reason (via activity log) |
+
+### Activity Log
+
+Server model uses Spatie ActivityLog for tracking changes:
+- Logged fields: name, ip, port, user, status
+- Only dirty attributes logged
+- Empty logs suppressed
+
+## Third-Party Security
+
+### Telescope
+
+- Sensitive headers hidden: `cookie`, `x-csrf-token`, `x-xsrf-token`
+- Sensitive parameters hidden: `_token`
+- Gate restricts to Hades users (production) or all users (local)
+
+### Horizon
+
+- Gate restricts to Hades users
+- Notifications configured via config (not hardcoded emails)
+
+## Security Checklist for New Features
+
+When adding new developer tools:
+
+- [ ] Enforce Hades authorization in middleware AND component
+- [ ] Add rate limiting for API endpoints
+- [ ] Redact sensitive data in output
+- [ ] Audit destructive operations
+- [ ] Restrict environment (local/testing) for dangerous features
+- [ ] Validate and sanitize all user input
+- [ ] Use prepared statements for database queries
+- [ ] Clean up temporary files/resources
+- [ ] Document security considerations
+
+## Incident Response
+
+### If Hades credentials are compromised:
+
+1. Revoke the user's Hades access
+2. Rotate `HADES_TOKEN` environment variable
+3. Review audit logs for suspicious activity
+4. Check server access logs for SSH activity
+5. Consider rotating SSH keys for connected servers
+
+### If SSH key is exposed:
+
+1. Delete the server record immediately
+2. Regenerate SSH key on the actual server
+3. Review server logs for unauthorized access
+4. Update the server record with new key
+
+## Recommendations for Production
+
+1. **Separate Hades token per environment** - Don't use same token across staging/production
+2. **Regular audit log review** - Monitor for unusual access patterns
+3. **Limit Hades users** - Only grant to essential personnel
+4. **Use hardware keys** - For servers, prefer hardware security modules
+5. **Network segmentation** - Restrict admin panel to internal networks
+6. **Two-factor authentication** - Require 2FA for Hades-tier accounts
+7. **Session timeout** - Consider shorter session duration for Hades users
diff --git a/docs/packages/index.md b/docs/packages/index.md
new file mode 100644
index 0000000..2e50fb3
--- /dev/null
+++ b/docs/packages/index.md
@@ -0,0 +1,192 @@
+---
+title: Packages
+---
+
+
+
+
+
+# Packages
+
+Browse the Host UK package ecosystem.
+
+
+
+
+
+
+
+
+ No packages match "{{ search }}"
+
diff --git a/docs/packages/mcp/analytics.md b/docs/packages/mcp/analytics.md
new file mode 100644
index 0000000..2ad38f7
--- /dev/null
+++ b/docs/packages/mcp/analytics.md
@@ -0,0 +1,436 @@
+# Tool Analytics
+
+Track MCP tool usage, performance, and patterns with comprehensive analytics.
+
+## Overview
+
+The MCP analytics system provides insights into:
+- Tool execution frequency
+- Performance metrics
+- Error rates
+- User patterns
+- Workspace usage
+
+## Recording Metrics
+
+### Automatic Tracking
+
+Tool executions are automatically tracked:
+
+```php
+use Core\Mcp\Listeners\RecordToolExecution;
+use Core\Mcp\Events\ToolExecuted;
+
+// Automatically recorded on tool execution
+event(new ToolExecuted(
+ tool: 'query_database',
+ workspace: $workspace,
+ user: $user,
+ duration: 5.23,
+ success: true
+));
+```
+
+### Manual Recording
+
+```php
+use Core\Mcp\Services\ToolAnalyticsService;
+
+$analytics = app(ToolAnalyticsService::class);
+
+$analytics->record([
+ 'tool_name' => 'query_database',
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ 'execution_time_ms' => 5.23,
+ 'success' => true,
+ 'error_message' => null,
+ 'metadata' => [
+ 'query_rows' => 42,
+ 'connection' => 'mysql',
+ ],
+]);
+```
+
+## Querying Analytics
+
+### Tool Stats
+
+```php
+use Core\Mcp\Services\ToolAnalyticsService;
+
+$analytics = app(ToolAnalyticsService::class);
+
+// Get stats for specific tool
+$stats = $analytics->getToolStats('query_database', [
+ 'workspace_id' => $workspace->id,
+ 'start_date' => now()->subDays(30),
+ 'end_date' => now(),
+]);
+```
+
+**Returns:**
+
+```php
+use Core\Mcp\DTO\ToolStats;
+
+$stats = new ToolStats(
+ tool_name: 'query_database',
+ total_executions: 1234,
+ successful_executions: 1200,
+ failed_executions: 34,
+ avg_execution_time_ms: 5.23,
+ p95_execution_time_ms: 12.45,
+ p99_execution_time_ms: 24.67,
+ error_rate: 2.76, // percentage
+);
+```
+
+### Most Used Tools
+
+```php
+$topTools = $analytics->mostUsedTools([
+ 'workspace_id' => $workspace->id,
+ 'limit' => 10,
+ 'start_date' => now()->subDays(7),
+]);
+
+// Returns array:
+[
+ ['tool_name' => 'query_database', 'count' => 500],
+ ['tool_name' => 'list_workspaces', 'count' => 120],
+ ['tool_name' => 'get_billing_status', 'count' => 45],
+]
+```
+
+### Error Analysis
+
+```php
+// Get failed executions
+$errors = $analytics->getErrors([
+ 'workspace_id' => $workspace->id,
+ 'tool_name' => 'query_database',
+ 'start_date' => now()->subDays(7),
+]);
+
+foreach ($errors as $error) {
+ echo "Error: {$error->error_message}\n";
+ echo "Occurred: {$error->created_at->diffForHumans()}\n";
+ echo "User: {$error->user->name}\n";
+}
+```
+
+### Performance Trends
+
+```php
+// Get daily execution counts
+$trend = $analytics->dailyTrend([
+ 'tool_name' => 'query_database',
+ 'workspace_id' => $workspace->id,
+ 'days' => 30,
+]);
+
+// Returns:
+[
+ '2026-01-01' => 45,
+ '2026-01-02' => 52,
+ '2026-01-03' => 48,
+ // ...
+]
+```
+
+## Admin Dashboard
+
+View analytics in admin panel:
+
+```php
+ $analytics->totalExecutions(),
+ 'topTools' => $analytics->mostUsedTools(['limit' => 10]),
+ 'errorRate' => $analytics->errorRate(),
+ 'avgExecutionTime' => $analytics->averageExecutionTime(),
+ ]);
+ }
+}
+```
+
+**View:**
+
+```blade
+
+
+ MCP Tool Analytics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Most Used Tools
+
+
+ Tool
+ Executions
+
+
+ @foreach($topTools as $tool)
+
+ {{ $tool['tool_name'] }}
+ {{ number_format($tool['count']) }}
+
+ @endforeach
+
+
+
+```
+
+## Tool Detail View
+
+Detailed analytics for specific tool:
+
+```blade
+
+
+ {{ $toolName }} Analytics
+
+
+
+
+
+
+
+
+
+
+
+
Performance Trend
+
+
+
+
+
Recent Errors
+ @foreach($recentErrors as $error)
+
+ {{ $error->created_at->diffForHumans() }}
+ {{ $error->error_message }}
+
+ @endforeach
+
+
+```
+
+## Pruning Old Metrics
+
+```bash
+# Prune metrics older than 90 days
+php artisan mcp:prune-metrics --days=90
+
+# Dry run
+php artisan mcp:prune-metrics --days=90 --dry-run
+```
+
+**Scheduled Pruning:**
+
+```php
+// app/Console/Kernel.php
+protected function schedule(Schedule $schedule)
+{
+ $schedule->command('mcp:prune-metrics --days=90')
+ ->daily()
+ ->at('02:00');
+}
+```
+
+## Alerting
+
+Set up alerts for anomalies:
+
+```php
+use Core\Mcp\Services\ToolAnalyticsService;
+
+$analytics = app(ToolAnalyticsService::class);
+
+// Check error rate
+$errorRate = $analytics->errorRate([
+ 'tool_name' => 'query_database',
+ 'start_date' => now()->subHours(1),
+]);
+
+if ($errorRate > 10) {
+ // Alert: High error rate
+ Notification::route('slack', config('slack.webhook'))
+ ->notify(new HighErrorRateNotification('query_database', $errorRate));
+}
+
+// Check slow executions
+$p99 = $analytics->getToolStats('query_database')->p99_execution_time_ms;
+
+if ($p99 > 1000) {
+ // Alert: Slow performance
+ Notification::route('slack', config('slack.webhook'))
+ ->notify(new SlowToolNotification('query_database', $p99));
+}
+```
+
+## Export Analytics
+
+```php
+use Core\Mcp\Services\ToolAnalyticsService;
+
+$analytics = app(ToolAnalyticsService::class);
+
+// Export to CSV
+$csv = $analytics->exportToCsv([
+ 'workspace_id' => $workspace->id,
+ 'start_date' => now()->subDays(30),
+ 'end_date' => now(),
+]);
+
+return response()->streamDownload(function () use ($csv) {
+ echo $csv;
+}, 'mcp-analytics.csv');
+```
+
+## Best Practices
+
+### 1. Set Retention Policies
+
+```php
+// config/mcp.php
+return [
+ 'analytics' => [
+ 'retention_days' => 90, // Keep 90 days
+ 'prune_schedule' => 'daily',
+ ],
+];
+```
+
+### 2. Monitor Error Rates
+
+```php
+// ✅ Good - alert on high error rate
+if ($errorRate > 10) {
+ $this->alert('High error rate');
+}
+
+// ❌ Bad - ignore errors
+// (problems go unnoticed)
+```
+
+### 3. Track Performance
+
+```php
+// ✅ Good - measure execution time
+$start = microtime(true);
+$result = $tool->execute($params);
+$duration = (microtime(true) - $start) * 1000;
+
+$analytics->record([
+ 'execution_time_ms' => $duration,
+]);
+```
+
+### 4. Use Aggregated Queries
+
+```php
+// ✅ Good - use analytics service
+$stats = $analytics->getToolStats('query_database');
+
+// ❌ Bad - query metrics table directly
+$count = ToolMetric::where('tool_name', 'query_database')->count();
+```
+
+## Testing
+
+```php
+use Tests\TestCase;
+use Core\Mcp\Services\ToolAnalyticsService;
+
+class AnalyticsTest extends TestCase
+{
+ public function test_records_tool_execution(): void
+ {
+ $analytics = app(ToolAnalyticsService::class);
+
+ $analytics->record([
+ 'tool_name' => 'test_tool',
+ 'workspace_id' => 1,
+ 'success' => true,
+ ]);
+
+ $this->assertDatabaseHas('mcp_tool_metrics', [
+ 'tool_name' => 'test_tool',
+ 'workspace_id' => 1,
+ ]);
+ }
+
+ public function test_calculates_error_rate(): void
+ {
+ $analytics = app(ToolAnalyticsService::class);
+
+ // Record 100 successful, 10 failed
+ for ($i = 0; $i < 100; $i++) {
+ $analytics->record(['tool_name' => 'test', 'success' => true]);
+ }
+ for ($i = 0; $i < 10; $i++) {
+ $analytics->record(['tool_name' => 'test', 'success' => false]);
+ }
+
+ $errorRate = $analytics->errorRate(['tool_name' => 'test']);
+
+ $this->assertEquals(9.09, round($errorRate, 2)); // 10/110 = 9.09%
+ }
+}
+```
+
+## Learn More
+
+- [Quotas →](/packages/mcp/quotas)
+- [Creating Tools →](/packages/mcp/tools)
diff --git a/docs/packages/mcp/creating-mcp-tools.md b/docs/packages/mcp/creating-mcp-tools.md
new file mode 100644
index 0000000..f309801
--- /dev/null
+++ b/docs/packages/mcp/creating-mcp-tools.md
@@ -0,0 +1,787 @@
+# Guide: Creating MCP Tools
+
+This guide covers everything you need to create MCP tools for AI agents, from basic tools to advanced patterns with workspace context, dependencies, and security best practices.
+
+## Overview
+
+MCP (Model Context Protocol) tools allow AI agents to interact with your application. Each tool:
+
+- Has a unique name and description
+- Defines input parameters with JSON Schema
+- Executes logic and returns structured responses
+- Can require workspace context for multi-tenant isolation
+- Can declare dependencies on other tools
+
+## Tool Interface
+
+All MCP tools extend `Laravel\Mcp\Server\Tool` and implement two required methods:
+
+```php
+get();
+
+ return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT));
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'status' => $schema->string('Filter by post status'),
+ 'limit' => $schema->integer('Maximum posts to return')->default(10),
+ ];
+ }
+}
+```
+
+### Key Methods
+
+| Method | Purpose |
+|--------|---------|
+| `$description` | Tool description shown to AI agents |
+| `handle(Request)` | Execute the tool and return a Response |
+| `schema(JsonSchema)` | Define input parameters |
+
+## Parameter Validation
+
+Define parameters using the `JsonSchema` builder in the `schema()` method:
+
+### String Parameters
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ // Basic string
+ 'title' => $schema->string('Post title')->required(),
+
+ // Enum values
+ 'status' => $schema->string('Post status: draft, published, archived'),
+
+ // With default
+ 'format' => $schema->string('Output format')->default('json'),
+ ];
+}
+```
+
+### Numeric Parameters
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ // Integer
+ 'limit' => $schema->integer('Maximum results')->default(10),
+
+ // Number (float)
+ 'price' => $schema->number('Product price'),
+ ];
+}
+```
+
+### Boolean Parameters
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ 'include_drafts' => $schema->boolean('Include draft posts')->default(false),
+ ];
+}
+```
+
+### Array Parameters
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ 'tags' => $schema->array('Filter by tags'),
+ 'ids' => $schema->array('Specific post IDs to fetch'),
+ ];
+}
+```
+
+### Required vs Optional
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ // Required - AI agent must provide this
+ 'query' => $schema->string('SQL query to execute')->required(),
+
+ // Optional with default
+ 'limit' => $schema->integer('Max rows')->default(100),
+
+ // Optional without default
+ 'status' => $schema->string('Filter status'),
+ ];
+}
+```
+
+### Accessing Parameters
+
+```php
+public function handle(Request $request): Response
+{
+ // Get single parameter
+ $query = $request->input('query');
+
+ // Get with default
+ $limit = $request->input('limit', 10);
+
+ // Check if parameter exists
+ if ($request->has('status')) {
+ // ...
+ }
+
+ // Get all parameters
+ $params = $request->all();
+}
+```
+
+### Custom Validation
+
+For validation beyond schema types, validate in `handle()`:
+
+```php
+public function handle(Request $request): Response
+{
+ $email = $request->input('email');
+
+ // Custom validation
+ if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return Response::text(json_encode([
+ 'error' => 'Invalid email format',
+ 'code' => 'VALIDATION_ERROR',
+ ]));
+ }
+
+ // Validate limit range
+ $limit = $request->input('limit', 10);
+ if ($limit < 1 || $limit > 100) {
+ return Response::text(json_encode([
+ 'error' => 'Limit must be between 1 and 100',
+ 'code' => 'VALIDATION_ERROR',
+ ]));
+ }
+
+ // Continue with tool logic...
+}
+```
+
+## Workspace Context
+
+For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access.
+
+### Using RequiresWorkspaceContext
+
+```php
+getWorkspace();
+ $workspaceId = $this->getWorkspaceId();
+
+ $posts = Post::where('workspace_id', $workspaceId)
+ ->limit($request->input('limit', 10))
+ ->get();
+
+ return Response::text(json_encode([
+ 'workspace' => $workspace->name,
+ 'posts' => $posts->toArray(),
+ ], JSON_PRETTY_PRINT));
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ // Note: No workspace_id parameter - comes from auth context
+ return [
+ 'limit' => $schema->integer('Maximum posts to return'),
+ ];
+ }
+}
+```
+
+### Trait Methods
+
+The `RequiresWorkspaceContext` trait provides:
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getWorkspaceContext()` | `WorkspaceContext` | Full context object |
+| `getWorkspaceId()` | `int` | Workspace ID only |
+| `getWorkspace()` | `Workspace` | Workspace model |
+| `hasWorkspaceContext()` | `bool` | Check if context available |
+| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace |
+
+### Setting Workspace Context
+
+Workspace context is set by middleware from authentication (API key or user session):
+
+```php
+// In middleware or controller
+$tool = new ListWorkspacePostsTool();
+$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace));
+
+// Or from ID
+$tool->setWorkspaceId($workspaceId);
+
+// Or from workspace model
+$tool->setWorkspace($workspace);
+```
+
+### Validating Resource Ownership
+
+When accessing specific resources, validate they belong to the workspace:
+
+```php
+public function handle(Request $request): Response
+{
+ $postId = $request->input('post_id');
+ $post = Post::findOrFail($postId);
+
+ // Throws RuntimeException if post doesn't belong to workspace
+ $this->validateResourceOwnership($post->workspace_id, 'post');
+
+ // Safe to proceed
+ return Response::text(json_encode($post->toArray()));
+}
+```
+
+## Tool Dependencies
+
+Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order.
+
+### Declaring Dependencies
+
+Implement `HasDependencies` or use `ValidatesDependencies` trait:
+
+```php
+ 'plan_id',
+]);
+
+// Custom validation
+ToolDependency::custom('billing_active', 'Billing must be active');
+```
+
+### Optional Dependencies
+
+Mark dependencies as optional (warns but doesn't block):
+
+```php
+public function dependencies(): array
+{
+ return [
+ ToolDependency::toolCalled('cache_warm')
+ ->asOptional(), // Soft dependency
+ ];
+}
+```
+
+### Inline Dependency Validation
+
+Use the `ValidatesDependencies` trait for inline validation:
+
+```php
+use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies;
+
+class MyTool extends Tool
+{
+ use ValidatesDependencies;
+
+ public function handle(Request $request): Response
+ {
+ $context = ['session_id' => $request->input('session_id')];
+
+ // Throws if dependencies not met
+ $this->validateDependencies($context);
+
+ // Or check without throwing
+ if (!$this->dependenciesMet($context)) {
+ $missing = $this->getMissingDependencies($context);
+ return Response::text(json_encode([
+ 'error' => 'Dependencies not met',
+ 'missing' => array_map(fn($d) => $d->key, $missing),
+ ]));
+ }
+
+ // Continue...
+ }
+}
+```
+
+## Registering Tools
+
+Register tools via the `McpToolsRegistering` event in your module:
+
+```php
+ 'onMcpTools',
+ ];
+
+ public function onMcpTools(McpToolsRegistering $event): void
+ {
+ $event->tool('blog:list-posts', ListPostsTool::class);
+ $event->tool('blog:create-post', CreatePostTool::class);
+ }
+}
+```
+
+### Tool Naming Conventions
+
+Use consistent naming:
+
+```php
+// Pattern: module:action-resource
+'blog:list-posts' // List resources
+'blog:get-post' // Get single resource
+'blog:create-post' // Create resource
+'blog:update-post' // Update resource
+'blog:delete-post' // Delete resource
+
+// Sub-modules
+'commerce:billing:get-status'
+'commerce:coupon:create'
+```
+
+## Response Formats
+
+### Success Response
+
+```php
+return Response::text(json_encode([
+ 'success' => true,
+ 'data' => $result,
+], JSON_PRETTY_PRINT));
+```
+
+### Error Response
+
+```php
+return Response::text(json_encode([
+ 'error' => 'Specific error message',
+ 'code' => 'ERROR_CODE',
+]));
+```
+
+### Paginated Response
+
+```php
+$posts = Post::paginate($perPage);
+
+return Response::text(json_encode([
+ 'data' => $posts->items(),
+ 'pagination' => [
+ 'current_page' => $posts->currentPage(),
+ 'last_page' => $posts->lastPage(),
+ 'per_page' => $posts->perPage(),
+ 'total' => $posts->total(),
+ ],
+], JSON_PRETTY_PRINT));
+```
+
+### List Response
+
+```php
+return Response::text(json_encode([
+ 'count' => $items->count(),
+ 'items' => $items->map(fn($item) => [
+ 'id' => $item->id,
+ 'name' => $item->name,
+ ])->all(),
+], JSON_PRETTY_PRINT));
+```
+
+## Security Best Practices
+
+### 1. Never Trust User-Supplied IDs for Authorization
+
+```php
+// BAD: Using workspace_id from request
+public function handle(Request $request): Response
+{
+ $workspaceId = $request->input('workspace_id'); // Attacker can change this!
+ $posts = Post::where('workspace_id', $workspaceId)->get();
+}
+
+// GOOD: Using authenticated workspace context
+public function handle(Request $request): Response
+{
+ $workspaceId = $this->getWorkspaceId(); // From auth context
+ $posts = Post::where('workspace_id', $workspaceId)->get();
+}
+```
+
+### 2. Validate Resource Ownership
+
+```php
+public function handle(Request $request): Response
+{
+ $postId = $request->input('post_id');
+ $post = Post::findOrFail($postId);
+
+ // Always validate ownership before access
+ $this->validateResourceOwnership($post->workspace_id, 'post');
+
+ return Response::text(json_encode($post->toArray()));
+}
+```
+
+### 3. Sanitize and Limit Input
+
+```php
+public function handle(Request $request): Response
+{
+ // Limit result sets
+ $limit = min($request->input('limit', 10), 100);
+
+ // Sanitize string input
+ $search = strip_tags($request->input('search', ''));
+ $search = substr($search, 0, 255);
+
+ // Validate enum values
+ $status = $request->input('status');
+ if ($status && !in_array($status, ['draft', 'published', 'archived'])) {
+ return Response::text(json_encode(['error' => 'Invalid status']));
+ }
+}
+```
+
+### 4. Log Sensitive Operations
+
+```php
+public function handle(Request $request): Response
+{
+ Log::info('MCP tool executed', [
+ 'tool' => 'delete-post',
+ 'workspace_id' => $this->getWorkspaceId(),
+ 'post_id' => $request->input('post_id'),
+ 'user' => auth()->id(),
+ ]);
+
+ // Perform operation...
+}
+```
+
+### 5. Use Read-Only Database Connections for Queries
+
+```php
+// For query tools, use read-only connection
+$connection = config('mcp.database.connection', 'readonly');
+$results = DB::connection($connection)->select($query);
+```
+
+### 6. Sanitize Error Messages
+
+```php
+try {
+ // Operation...
+} catch (\Exception $e) {
+ // Log full error for debugging
+ report($e);
+
+ // Return sanitized message to client
+ return Response::text(json_encode([
+ 'error' => 'Operation failed. Please try again.',
+ 'code' => 'OPERATION_FAILED',
+ ]));
+}
+```
+
+### 7. Implement Rate Limiting
+
+Tools should respect quota limits:
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+public function handle(Request $request): Response
+{
+ $quota = app(McpQuotaService::class);
+ $workspace = $this->getWorkspace();
+
+ if (!$quota->canExecute($workspace, $this->name())) {
+ return Response::text(json_encode([
+ 'error' => 'Rate limit exceeded',
+ 'code' => 'QUOTA_EXCEEDED',
+ ]));
+ }
+
+ // Execute tool...
+
+ $quota->recordExecution($workspace, $this->name());
+}
+```
+
+## Testing Tools
+
+```php
+create();
+ Post::factory()->count(5)->create([
+ 'workspace_id' => $workspace->id,
+ ]);
+
+ $tool = new ListPostsTool();
+ $tool->setWorkspaceContext(
+ WorkspaceContext::fromWorkspace($workspace)
+ );
+
+ $request = new \Laravel\Mcp\Request([
+ 'limit' => 10,
+ ]);
+
+ $response = $tool->handle($request);
+ $data = json_decode($response->getContent(), true);
+
+ $this->assertCount(5, $data['posts']);
+ }
+
+ public function test_respects_workspace_isolation(): void
+ {
+ $workspace1 = Workspace::factory()->create();
+ $workspace2 = Workspace::factory()->create();
+
+ Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
+ Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]);
+
+ $tool = new ListPostsTool();
+ $tool->setWorkspace($workspace1);
+
+ $request = new \Laravel\Mcp\Request([]);
+ $response = $tool->handle($request);
+ $data = json_decode($response->getContent(), true);
+
+ // Should only see workspace1's posts
+ $this->assertCount(3, $data['posts']);
+ }
+
+ public function test_throws_without_workspace_context(): void
+ {
+ $this->expectException(MissingWorkspaceContextException::class);
+
+ $tool = new ListPostsTool();
+ // Not setting workspace context
+
+ $tool->handle(new \Laravel\Mcp\Request([]));
+ }
+}
+```
+
+## Complete Example
+
+Here's a complete tool implementation following all best practices:
+
+```php
+getWorkspaceId();
+
+ // Validate and sanitize inputs
+ $status = $request->input('status');
+ if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) {
+ return Response::text(json_encode([
+ 'error' => 'Invalid status. Use: paid, pending, overdue, void',
+ 'code' => 'VALIDATION_ERROR',
+ ]));
+ }
+
+ $limit = min($request->input('limit', 10), 50);
+
+ // Query with workspace scope
+ $query = Invoice::with('order')
+ ->where('workspace_id', $workspaceId)
+ ->latest();
+
+ if ($status) {
+ $query->where('status', $status);
+ }
+
+ $invoices = $query->limit($limit)->get();
+
+ return Response::text(json_encode([
+ 'workspace_id' => $workspaceId,
+ 'count' => $invoices->count(),
+ 'invoices' => $invoices->map(fn ($invoice) => [
+ 'id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'status' => $invoice->status,
+ 'total' => (float) $invoice->total,
+ 'currency' => $invoice->currency,
+ 'issue_date' => $invoice->issue_date?->toDateString(),
+ 'due_date' => $invoice->due_date?->toDateString(),
+ 'is_overdue' => $invoice->isOverdue(),
+ ])->all(),
+ ], JSON_PRETTY_PRINT));
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
+ 'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'),
+ ];
+ }
+}
+```
+
+## Learn More
+
+- [SQL Security](/packages/mcp/sql-security) - Safe query patterns
+- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
+- [Tool Analytics](/packages/mcp/analytics) - Usage tracking
+- [Quotas](/packages/mcp/quotas) - Rate limiting
diff --git a/docs/packages/mcp/index.md b/docs/packages/mcp/index.md
new file mode 100644
index 0000000..1424588
--- /dev/null
+++ b/docs/packages/mcp/index.md
@@ -0,0 +1,436 @@
+# MCP Package
+
+The MCP (Model Context Protocol) package provides AI agent tools for database queries, commerce operations, and workspace management with built-in security and quota enforcement.
+
+## Installation
+
+```bash
+composer require host-uk/core-mcp
+```
+
+## Quick Start
+
+```php
+ 'onMcpTools',
+ ];
+
+ public function onMcpTools(McpToolsRegistering $event): void
+ {
+ $event->tool('blog:create-post', Tools\CreatePostTool::class);
+ $event->tool('blog:list-posts', Tools\ListPostsTool::class);
+ }
+}
+```
+
+## Key Features
+
+### Database Tools
+
+- **[Query Database](/packages/mcp/query-database)** - SQL query execution with validation and security
+- **[SQL Validation](/packages/mcp/security#sql-validation)** - Prevent destructive queries and SQL injection
+- **[EXPLAIN Plans](/packages/mcp/query-database#explain)** - Query optimization analysis
+
+### Commerce Tools
+
+- **[Get Billing Status](/packages/mcp/commerce#billing)** - Current billing and subscription status
+- **[List Invoices](/packages/mcp/commerce#invoices)** - Invoice history and details
+- **[Upgrade Plan](/packages/mcp/commerce#upgrades)** - Tier upgrades with entitlement validation
+
+### Workspace Tools
+
+- **[Workspace Context](/packages/mcp/workspace)** - Automatic workspace/namespace resolution
+- **[Quota Enforcement](/packages/mcp/quotas)** - Tool usage limits and monitoring
+- **[Tool Analytics](/packages/mcp/analytics)** - Usage tracking and statistics
+
+### Developer Tools
+
+- **[Tool Discovery](/packages/mcp/tools#discovery)** - Automatic tool registration
+- **[Dependency Management](/packages/mcp/tools#dependencies)** - Tool dependency resolution
+- **[Error Handling](/packages/mcp/tools#errors)** - Consistent error responses
+
+## Creating Tools
+
+### Basic Tool
+
+```php
+ [
+ 'type' => 'string',
+ 'description' => 'Filter by status',
+ 'enum' => ['published', 'draft'],
+ 'required' => false,
+ ],
+ 'limit' => [
+ 'type' => 'integer',
+ 'description' => 'Number of posts to return',
+ 'default' => 10,
+ 'required' => false,
+ ],
+ ];
+ }
+
+ public function execute(array $params): array
+ {
+ $query = Post::query();
+
+ if (isset($params['status'])) {
+ $query->where('status', $params['status']);
+ }
+
+ $posts = $query->limit($params['limit'] ?? 10)->get();
+
+ return [
+ 'posts' => $posts->map(fn ($post) => [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ 'slug' => $post->slug,
+ 'status' => $post->status,
+ ])->toArray(),
+ ];
+ }
+}
+```
+
+### Tool with Workspace Context
+
+```php
+getWorkspaceContext();
+
+ $post = Post::create([
+ 'title' => $params['title'],
+ 'content' => $params['content'],
+ 'workspace_id' => $workspace->id,
+ ]);
+
+ return [
+ 'success' => true,
+ 'post_id' => $post->id,
+ ];
+ }
+}
+```
+
+### Tool with Dependencies
+
+```php
+execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+ 'connection' => 'mysql',
+]);
+
+// Returns:
+// [
+// 'rows' => [...],
+// 'count' => 10,
+// 'execution_time_ms' => 5.23
+// ]
+```
+
+### Security Features
+
+- **Whitelist-based validation** - Only SELECT queries allowed by default
+- **No destructive operations** - DROP, TRUNCATE, DELETE blocked
+- **Binding enforcement** - Prevents SQL injection
+- **Connection validation** - Only allowed connections accessible
+- **EXPLAIN analysis** - Query optimization insights
+
+[Learn more about SQL Security →](/packages/mcp/security)
+
+## Quota System
+
+Enforce tool usage limits per workspace:
+
+```php
+// config/mcp.php
+'quotas' => [
+ 'enabled' => true,
+ 'limits' => [
+ 'free' => ['calls' => 100, 'per' => 'day'],
+ 'pro' => ['calls' => 1000, 'per' => 'day'],
+ 'business' => ['calls' => 10000, 'per' => 'day'],
+ 'enterprise' => ['calls' => null], // Unlimited
+ ],
+],
+```
+
+Check quota before execution:
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+$quotaService = app(McpQuotaService::class);
+
+if (!$quotaService->canExecute($workspace, 'blog:create-post')) {
+ throw new QuotaExceededException('Daily tool quota exceeded');
+}
+
+$quotaService->recordExecution($workspace, 'blog:create-post');
+```
+
+[Learn more about Quotas →](/packages/mcp/quotas)
+
+## Tool Analytics
+
+Track tool usage and performance:
+
+```php
+use Core\Mcp\Services\ToolAnalyticsService;
+
+$analytics = app(ToolAnalyticsService::class);
+
+// Get tool stats
+$stats = $analytics->getToolStats('blog:create-post', period: 'week');
+// Returns: ToolStats with executions, errors, avg_duration_ms
+
+// Get workspace usage
+$usage = $analytics->getWorkspaceUsage($workspace, period: 'month');
+
+// Get most used tools
+$topTools = $analytics->getTopTools(limit: 10, period: 'week');
+```
+
+[Learn more about Analytics →](/packages/mcp/analytics)
+
+## Configuration
+
+```php
+// config/mcp.php
+return [
+ 'enabled' => true,
+
+ 'tools' => [
+ 'auto_discover' => true,
+ 'cache_enabled' => true,
+ ],
+
+ 'query_database' => [
+ 'allowed_connections' => ['mysql', 'pgsql'],
+ 'forbidden_keywords' => [
+ 'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
+ 'ALTER', 'CREATE', 'GRANT', 'REVOKE',
+ ],
+ 'max_execution_time' => 5000, // ms
+ 'enable_explain' => true,
+ ],
+
+ 'quotas' => [
+ 'enabled' => true,
+ 'limits' => [
+ 'free' => ['calls' => 100, 'per' => 'day'],
+ 'pro' => ['calls' => 1000, 'per' => 'day'],
+ 'business' => ['calls' => 10000, 'per' => 'day'],
+ 'enterprise' => ['calls' => null],
+ ],
+ ],
+
+ 'analytics' => [
+ 'enabled' => true,
+ 'retention_days' => 90,
+ ],
+];
+```
+
+## Middleware
+
+```php
+use Core\Mcp\Middleware\ValidateWorkspaceContext;
+use Core\Mcp\Middleware\CheckMcpQuota;
+use Core\Mcp\Middleware\ValidateToolDependencies;
+
+Route::middleware([
+ ValidateWorkspaceContext::class,
+ CheckMcpQuota::class,
+ ValidateToolDependencies::class,
+])->group(function () {
+ // MCP tool routes
+});
+```
+
+## Best Practices
+
+### 1. Use Workspace Context
+
+```php
+// ✅ Good - workspace aware
+class CreatePostTool extends BaseTool
+{
+ use RequiresWorkspaceContext;
+}
+
+// ❌ Bad - no workspace context
+class CreatePostTool extends BaseTool
+{
+ public function execute(array $params): array
+ {
+ $post = Post::create($params); // No workspace_id!
+ }
+}
+```
+
+### 2. Validate Parameters
+
+```php
+// ✅ Good - strict validation
+public function getParameters(): array
+{
+ return [
+ 'title' => [
+ 'type' => 'string',
+ 'required' => true,
+ 'maxLength' => 255,
+ ],
+ ];
+}
+```
+
+### 3. Handle Errors Gracefully
+
+```php
+// ✅ Good - clear error messages
+public function execute(array $params): array
+{
+ try {
+ return ['success' => true, 'data' => $result];
+ } catch (\Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ 'code' => 'TOOL_EXECUTION_FAILED',
+ ];
+ }
+}
+```
+
+### 4. Document Tools Well
+
+```php
+// ✅ Good - comprehensive description
+public function getDescription(): string
+{
+ return 'Create a new blog post with title, content, and optional metadata. '
+ . 'Requires workspace context. Validates entitlements before creation.';
+}
+```
+
+## Testing
+
+```php
+count(5)->create(['status' => 'published']);
+
+ $tool = new ListPostsTool();
+
+ $result = $tool->execute([
+ 'status' => 'published',
+ 'limit' => 10,
+ ]);
+
+ $this->assertArrayHasKey('posts', $result);
+ $this->assertCount(5, $result['posts']);
+ }
+}
+```
+
+## Learn More
+
+- [Query Database →](/packages/mcp/query-database)
+- [SQL Security →](/packages/mcp/security)
+- [Workspace Context →](/packages/mcp/workspace)
+- [Tool Analytics →](/packages/mcp/analytics)
+- [Quota System →](/packages/mcp/quotas)
diff --git a/docs/packages/mcp/query-database.md b/docs/packages/mcp/query-database.md
new file mode 100644
index 0000000..b6438b5
--- /dev/null
+++ b/docs/packages/mcp/query-database.md
@@ -0,0 +1,452 @@
+# Query Database Tool
+
+The MCP package provides a secure SQL query execution tool with validation, connection management, and EXPLAIN plan analysis.
+
+## Overview
+
+The Query Database tool allows AI agents to:
+- Execute SELECT queries safely
+- Analyze query performance
+- Access multiple database connections
+- Prevent destructive operations
+- Enforce workspace context
+
+## Basic Usage
+
+```php
+use Core\Mcp\Tools\QueryDatabase;
+
+$tool = new QueryDatabase();
+
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+ 'connection' => 'mysql',
+]);
+
+// Returns:
+// [
+// 'rows' => [...],
+// 'count' => 10,
+// 'execution_time_ms' => 5.23
+// ]
+```
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `query` | string | Yes | SQL SELECT query |
+| `bindings` | array | No | Query parameters (prevents SQL injection) |
+| `connection` | string | No | Database connection name (default: default) |
+| `explain` | bool | No | Include EXPLAIN plan analysis |
+
+## Security Validation
+
+### Allowed Operations
+
+✅ Only SELECT queries are allowed:
+
+```php
+// ✅ Allowed
+'SELECT * FROM posts'
+'SELECT id, title FROM posts WHERE status = ?'
+'SELECT COUNT(*) FROM users'
+
+// ❌ Blocked
+'DELETE FROM posts'
+'UPDATE posts SET status = ?'
+'DROP TABLE posts'
+'TRUNCATE posts'
+```
+
+### Forbidden Keywords
+
+The following are automatically blocked:
+- `DROP`
+- `TRUNCATE`
+- `DELETE`
+- `UPDATE`
+- `INSERT`
+- `ALTER`
+- `CREATE`
+- `GRANT`
+- `REVOKE`
+
+### Required WHERE Clauses
+
+Queries on large tables must include WHERE clauses:
+
+```php
+// ✅ Good - has WHERE clause
+'SELECT * FROM posts WHERE user_id = ?'
+
+// ⚠️ Warning - no WHERE clause
+'SELECT * FROM posts'
+// Returns warning if table has > 10,000 rows
+```
+
+### Connection Validation
+
+Only whitelisted connections are accessible:
+
+```php
+// config/mcp.php
+'query_database' => [
+ 'allowed_connections' => ['mysql', 'pgsql', 'analytics'],
+],
+```
+
+## EXPLAIN Plan Analysis
+
+Enable query optimization insights:
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+ 'explain' => true,
+]);
+
+// Returns additional 'explain' key:
+// [
+// 'rows' => [...],
+// 'explain' => [
+// 'type' => 'ref',
+// 'key' => 'idx_status',
+// 'rows_examined' => 150,
+// 'analysis' => 'Query uses index. Performance: Good',
+// 'recommendations' => []
+// ]
+// ]
+```
+
+### Performance Analysis
+
+The EXPLAIN analyzer provides human-readable insights:
+
+**Good Performance:**
+```
+"Query uses index. Performance: Good"
+```
+
+**Index Missing:**
+```
+"Warning: Full table scan detected. Consider adding an index on 'status'"
+```
+
+**High Row Count:**
+```
+"Warning: Query examines 50,000 rows. Consider adding WHERE clause to limit results"
+```
+
+## Examples
+
+### Basic SELECT
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT id, title, created_at FROM posts LIMIT 10',
+]);
+
+foreach ($result['rows'] as $row) {
+ echo "{$row['title']}\n";
+}
+```
+
+### With Parameters
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
+ 'bindings' => [42, 'published'],
+]);
+```
+
+### Aggregation
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT status, COUNT(*) as count FROM posts GROUP BY status',
+]);
+
+// Returns: [
+// ['status' => 'draft', 'count' => 15],
+// ['status' => 'published', 'count' => 42],
+// ]
+```
+
+### Join Query
+
+```php
+$result = $tool->execute([
+ 'query' => '
+ SELECT posts.title, users.name
+ FROM posts
+ JOIN users ON posts.user_id = users.id
+ WHERE posts.status = ?
+ LIMIT 10
+ ',
+ 'bindings' => ['published'],
+]);
+```
+
+### Date Filtering
+
+```php
+$result = $tool->execute([
+ 'query' => '
+ SELECT *
+ FROM posts
+ WHERE created_at >= ?
+ AND created_at < ?
+ ORDER BY created_at DESC
+ ',
+ 'bindings' => ['2024-01-01', '2024-02-01'],
+]);
+```
+
+## Multiple Connections
+
+Query different databases:
+
+```php
+// Main application database
+$posts = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ 'connection' => 'mysql',
+]);
+
+// Analytics database
+$stats = $tool->execute([
+ 'query' => 'SELECT * FROM page_views',
+ 'connection' => 'analytics',
+]);
+
+// PostgreSQL database
+$data = $tool->execute([
+ 'query' => 'SELECT * FROM logs',
+ 'connection' => 'pgsql',
+]);
+```
+
+## Error Handling
+
+### Forbidden Query
+
+```php
+$result = $tool->execute([
+ 'query' => 'DELETE FROM posts WHERE id = 1',
+]);
+
+// Returns:
+// [
+// 'success' => false,
+// 'error' => 'Forbidden query: DELETE operations not allowed',
+// 'code' => 'FORBIDDEN_QUERY'
+// ]
+```
+
+### Invalid Connection
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ 'connection' => 'unknown',
+]);
+
+// Returns:
+// [
+// 'success' => false,
+// 'error' => 'Connection "unknown" not allowed',
+// 'code' => 'INVALID_CONNECTION'
+// ]
+```
+
+### SQL Error
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM nonexistent_table',
+]);
+
+// Returns:
+// [
+// 'success' => false,
+// 'error' => 'Table "nonexistent_table" doesn\'t exist',
+// 'code' => 'SQL_ERROR'
+// ]
+```
+
+## Configuration
+
+```php
+// config/mcp.php
+'query_database' => [
+ // Allowed database connections
+ 'allowed_connections' => [
+ 'mysql',
+ 'pgsql',
+ 'analytics',
+ ],
+
+ // Forbidden SQL keywords
+ 'forbidden_keywords' => [
+ 'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
+ 'ALTER', 'CREATE', 'GRANT', 'REVOKE',
+ ],
+
+ // Maximum execution time (milliseconds)
+ 'max_execution_time' => 5000,
+
+ // Enable EXPLAIN plan analysis
+ 'enable_explain' => true,
+
+ // Warn on queries without WHERE clause for tables larger than:
+ 'warn_no_where_threshold' => 10000,
+],
+```
+
+## Workspace Context
+
+Queries are automatically scoped to the current workspace:
+
+```php
+// When workspace context is set
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+]);
+
+// Equivalent to:
+// 'SELECT * FROM posts WHERE workspace_id = ?'
+// with workspace_id automatically added
+```
+
+Disable automatic scoping:
+
+```php
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM global_settings',
+ 'ignore_workspace_scope' => true,
+]);
+```
+
+## Best Practices
+
+### 1. Always Use Bindings
+
+```php
+// ✅ Good - prevents SQL injection
+$tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE user_id = ?',
+ 'bindings' => [$userId],
+]);
+
+// ❌ Bad - vulnerable to SQL injection
+$tool->execute([
+ 'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
+]);
+```
+
+### 2. Limit Results
+
+```php
+// ✅ Good - limits results
+'SELECT * FROM posts LIMIT 100'
+
+// ❌ Bad - could return millions of rows
+'SELECT * FROM posts'
+```
+
+### 3. Use EXPLAIN for Optimization
+
+```php
+// ✅ Good - analyze slow queries
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+ 'explain' => true,
+]);
+
+if (isset($result['explain']['recommendations'])) {
+ foreach ($result['explain']['recommendations'] as $rec) {
+ error_log("Query optimization: {$rec}");
+ }
+}
+```
+
+### 4. Handle Errors Gracefully
+
+```php
+// ✅ Good - check for errors
+$result = $tool->execute([...]);
+
+if (!($result['success'] ?? true)) {
+ return [
+ 'error' => $result['error'],
+ 'code' => $result['code'],
+ ];
+}
+
+return $result['rows'];
+```
+
+## Testing
+
+```php
+create(['title' => 'Test Post']);
+
+ $tool = new QueryDatabase();
+
+ $result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE title = ?',
+ 'bindings' => ['Test Post'],
+ ]);
+
+ $this->assertTrue($result['success'] ?? true);
+ $this->assertCount(1, $result['rows']);
+ }
+
+ public function test_blocks_delete_query(): void
+ {
+ $tool = new QueryDatabase();
+
+ $result = $tool->execute([
+ 'query' => 'DELETE FROM posts WHERE id = 1',
+ ]);
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('FORBIDDEN_QUERY', $result['code']);
+ }
+
+ public function test_validates_connection(): void
+ {
+ $tool = new QueryDatabase();
+
+ $result = $tool->execute([
+ 'query' => 'SELECT 1',
+ 'connection' => 'invalid',
+ ]);
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('INVALID_CONNECTION', $result['code']);
+ }
+}
+```
+
+## Learn More
+
+- [SQL Security →](/packages/mcp/security)
+- [Workspace Context →](/packages/mcp/workspace)
+- [Tool Analytics →](/packages/mcp/analytics)
diff --git a/docs/packages/mcp/quotas.md b/docs/packages/mcp/quotas.md
new file mode 100644
index 0000000..4556a1d
--- /dev/null
+++ b/docs/packages/mcp/quotas.md
@@ -0,0 +1,405 @@
+# Usage Quotas
+
+Tier-based rate limiting and usage quotas for MCP tools.
+
+## Overview
+
+The quota system enforces usage limits based on workspace subscription tiers:
+
+**Tiers:**
+- **Free:** 60 requests/hour, 500 queries/day
+- **Pro:** 600 requests/hour, 10,000 queries/day
+- **Enterprise:** Unlimited
+
+## Quota Enforcement
+
+### Middleware
+
+```php
+use Core\Mcp\Middleware\CheckMcpQuota;
+
+Route::middleware([CheckMcpQuota::class])
+ ->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
+```
+
+**Process:**
+1. Identifies workspace from context
+2. Checks current usage against tier limits
+3. Allows or denies request
+4. Records usage on success
+
+### Manual Checking
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+$quota = app(McpQuotaService::class);
+
+// Check if within quota
+if (!$quota->withinLimit($workspace)) {
+ return response()->json([
+ 'error' => 'Quota exceeded',
+ 'message' => 'You have reached your hourly limit',
+ 'reset_at' => $quota->resetTime($workspace),
+ ], 429);
+}
+
+// Record usage
+$quota->recordUsage($workspace, 'query_database');
+```
+
+## Quota Configuration
+
+```php
+// config/mcp.php
+return [
+ 'quotas' => [
+ 'free' => [
+ 'requests_per_hour' => 60,
+ 'queries_per_day' => 500,
+ 'max_query_rows' => 1000,
+ ],
+ 'pro' => [
+ 'requests_per_hour' => 600,
+ 'queries_per_day' => 10000,
+ 'max_query_rows' => 10000,
+ ],
+ 'enterprise' => [
+ 'requests_per_hour' => null, // Unlimited
+ 'queries_per_day' => null,
+ 'max_query_rows' => 100000,
+ ],
+ ],
+];
+```
+
+## Usage Tracking
+
+### Current Usage
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+$quota = app(McpQuotaService::class);
+
+// Get current hour's usage
+$hourlyUsage = $quota->getHourlyUsage($workspace);
+
+// Get current day's usage
+$dailyUsage = $quota->getDailyUsage($workspace);
+
+// Get usage percentage
+$percentage = $quota->usagePercentage($workspace);
+```
+
+### Usage Response Headers
+
+```
+X-RateLimit-Limit: 60
+X-RateLimit-Remaining: 45
+X-RateLimit-Reset: 1706274000
+X-RateLimit-Reset-At: 2026-01-26T13:00:00Z
+```
+
+**Implementation:**
+
+```php
+use Core\Mcp\Middleware\CheckMcpQuota;
+
+class CheckMcpQuota
+{
+ public function handle($request, Closure $next)
+ {
+ $workspace = $request->workspace;
+ $quota = app(McpQuotaService::class);
+
+ $response = $next($request);
+
+ // Add quota headers
+ $response->headers->set('X-RateLimit-Limit', $quota->getLimit($workspace));
+ $response->headers->set('X-RateLimit-Remaining', $quota->getRemaining($workspace));
+ $response->headers->set('X-RateLimit-Reset', $quota->resetTime($workspace)->timestamp);
+
+ return $response;
+ }
+}
+```
+
+## Quota Exceeded Response
+
+```json
+{
+ "error": "quota_exceeded",
+ "message": "You have exceeded your hourly request limit",
+ "current_usage": 60,
+ "limit": 60,
+ "reset_at": "2026-01-26T13:00:00Z",
+ "upgrade_url": "https://example.com/billing/upgrade"
+}
+```
+
+**HTTP Status:** 429 Too Many Requests
+
+## Upgrading Tiers
+
+```php
+use Mod\Tenant\Models\Workspace;
+
+$workspace = Workspace::find($id);
+
+// Upgrade to Pro
+$workspace->update([
+ 'subscription_tier' => 'pro',
+]);
+
+// New limits immediately apply
+$quota = app(McpQuotaService::class);
+$newLimit = $quota->getLimit($workspace); // 600
+```
+
+## Quota Monitoring
+
+### Admin Dashboard
+
+```php
+class QuotaUsage extends Component
+{
+ public function render()
+ {
+ $quota = app(McpQuotaService::class);
+
+ $workspaces = Workspace::all()->map(function ($workspace) use ($quota) {
+ return [
+ 'name' => $workspace->name,
+ 'tier' => $workspace->subscription_tier,
+ 'hourly_usage' => $quota->getHourlyUsage($workspace),
+ 'hourly_limit' => $quota->getLimit($workspace, 'hourly'),
+ 'daily_usage' => $quota->getDailyUsage($workspace),
+ 'daily_limit' => $quota->getLimit($workspace, 'daily'),
+ ];
+ });
+
+ return view('mcp::admin.quota-usage', compact('workspaces'));
+ }
+}
+```
+
+**View:**
+
+```blade
+
+
+ Workspace
+ Tier
+ Hourly Usage
+ Daily Usage
+
+
+ @foreach($workspaces as $workspace)
+
+ {{ $workspace['name'] }}
+
+
+ {{ ucfirst($workspace['tier']) }}
+
+
+
+ {{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }}
+
+
+
+ {{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }}
+
+
+ @endforeach
+
+```
+
+### Alerts
+
+Send notifications when nearing limits:
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+$quota = app(McpQuotaService::class);
+
+$usage = $quota->usagePercentage($workspace);
+
+if ($usage >= 80) {
+ // Alert: 80% of quota used
+ $workspace->owner->notify(
+ new QuotaWarningNotification($workspace, $usage)
+ );
+}
+
+if ($usage >= 100) {
+ // Alert: Quota exceeded
+ $workspace->owner->notify(
+ new QuotaExceededNotification($workspace)
+ );
+}
+```
+
+## Custom Quotas
+
+Override default quotas for specific workspaces:
+
+```php
+use Core\Mcp\Models\McpUsageQuota;
+
+// Set custom quota
+McpUsageQuota::create([
+ 'workspace_id' => $workspace->id,
+ 'requests_per_hour' => 1000, // Custom limit
+ 'queries_per_day' => 50000,
+ 'expires_at' => now()->addMonths(3), // Temporary increase
+]);
+
+// Custom quota takes precedence over tier defaults
+```
+
+## Resetting Quotas
+
+```bash
+# Reset all quotas
+php artisan mcp:reset-quotas
+
+# Reset specific workspace
+php artisan mcp:reset-quotas --workspace=123
+
+# Reset specific period
+php artisan mcp:reset-quotas --period=hourly
+```
+
+## Bypass Quotas (Admin)
+
+```php
+// Bypass quota enforcement
+$result = $tool->execute($params, [
+ 'bypass_quota' => true, // Requires admin permission
+]);
+```
+
+**Use cases:**
+- Internal tools
+- Admin operations
+- System maintenance
+- Testing
+
+## Testing
+
+```php
+use Tests\TestCase;
+use Core\Mcp\Services\McpQuotaService;
+
+class QuotaTest extends TestCase
+{
+ public function test_enforces_hourly_limit(): void
+ {
+ $workspace = Workspace::factory()->create(['tier' => 'free']);
+ $quota = app(McpQuotaService::class);
+
+ // Exhaust quota
+ for ($i = 0; $i < 60; $i++) {
+ $quota->recordUsage($workspace, 'test');
+ }
+
+ $this->assertFalse($quota->withinLimit($workspace));
+ }
+
+ public function test_resets_after_hour(): void
+ {
+ $workspace = Workspace::factory()->create();
+ $quota = app(McpQuotaService::class);
+
+ // Use quota
+ $quota->recordUsage($workspace, 'test');
+
+ // Travel 1 hour
+ $this->travel(1)->hour();
+
+ $this->assertTrue($quota->withinLimit($workspace));
+ }
+
+ public function test_enterprise_has_no_limit(): void
+ {
+ $workspace = Workspace::factory()->create(['tier' => 'enterprise']);
+ $quota = app(McpQuotaService::class);
+
+ // Use quota 1000 times
+ for ($i = 0; $i < 1000; $i++) {
+ $quota->recordUsage($workspace, 'test');
+ }
+
+ $this->assertTrue($quota->withinLimit($workspace));
+ }
+}
+```
+
+## Best Practices
+
+### 1. Check Quotas Early
+
+```php
+// ✅ Good - check before processing
+if (!$quota->withinLimit($workspace)) {
+ return response()->json(['error' => 'Quota exceeded'], 429);
+}
+
+$result = $tool->execute($params);
+
+// ❌ Bad - check after processing
+$result = $tool->execute($params);
+if (!$quota->withinLimit($workspace)) {
+ // Too late!
+}
+```
+
+### 2. Provide Clear Feedback
+
+```php
+// ✅ Good - helpful error message
+return response()->json([
+ 'error' => 'Quota exceeded',
+ 'reset_at' => $quota->resetTime($workspace),
+ 'upgrade_url' => route('billing.upgrade'),
+], 429);
+
+// ❌ Bad - generic error
+return response()->json(['error' => 'Too many requests'], 429);
+```
+
+### 3. Monitor Usage Patterns
+
+```php
+// ✅ Good - alert at 80%
+if ($usage >= 80) {
+ $this->notifyUser();
+}
+
+// ❌ Bad - only alert when exhausted
+if ($usage >= 100) {
+ // User already hit limit
+}
+```
+
+### 4. Use Appropriate Limits
+
+```php
+// ✅ Good - reasonable limits
+'free' => ['requests_per_hour' => 60],
+'pro' => ['requests_per_hour' => 600],
+
+// ❌ Bad - too restrictive
+'free' => ['requests_per_hour' => 5], // Unusable
+```
+
+## Learn More
+
+- [Analytics →](/packages/mcp/analytics)
+- [Security →](/packages/mcp/security)
+- [Multi-Tenancy →](/packages/core/tenancy)
diff --git a/docs/packages/mcp/security.md b/docs/packages/mcp/security.md
new file mode 100644
index 0000000..61ecd3e
--- /dev/null
+++ b/docs/packages/mcp/security.md
@@ -0,0 +1,363 @@
+# MCP Security
+
+Security features for protecting database access and preventing SQL injection in MCP tools.
+
+## SQL Query Validation
+
+### Validation Rules
+
+The `SqlQueryValidator` enforces strict rules on all queries:
+
+**Allowed:**
+- `SELECT` statements only
+- Table/column qualifiers
+- WHERE clauses
+- JOINs
+- ORDER BY, GROUP BY
+- LIMIT clauses
+- Subqueries (SELECT only)
+
+**Forbidden:**
+- `INSERT`, `UPDATE`, `DELETE`, `DROP`, `CREATE`, `ALTER`
+- `TRUNCATE`, `GRANT`, `REVOKE`
+- Database modification operations
+- System table access
+- Multiple statements (`;` separated)
+
+### Usage
+
+```php
+use Core\Mcp\Services\SqlQueryValidator;
+
+$validator = app(SqlQueryValidator::class);
+
+// Valid query
+$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
+// Returns: ['valid' => true]
+
+// Invalid query
+$result = $validator->validate('DROP TABLE users');
+// Returns: ['valid' => false, 'error' => 'Only SELECT queries are allowed']
+```
+
+### Forbidden Patterns
+
+```php
+// ❌ Data modification
+DELETE FROM users WHERE id = 1
+UPDATE posts SET status = 'published'
+INSERT INTO logs VALUES (...)
+
+// ❌ Schema changes
+DROP TABLE posts
+ALTER TABLE users ADD COLUMN...
+CREATE INDEX...
+
+// ❌ Permission changes
+GRANT ALL ON *.* TO user
+REVOKE SELECT ON posts FROM user
+
+// ❌ Multiple statements
+SELECT * FROM posts; DROP TABLE users;
+
+// ❌ System tables
+SELECT * FROM information_schema.tables
+SELECT * FROM mysql.user
+```
+
+### Parameterized Queries
+
+Always use bindings to prevent SQL injection:
+
+```php
+// ✅ Good - parameterized
+$tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
+ 'bindings' => [$userId, 'published'],
+]);
+
+// ❌ Bad - SQL injection risk
+$tool->execute([
+ 'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
+]);
+```
+
+## Workspace Context Security
+
+### Automatic Scoping
+
+Queries are automatically scoped to the current workspace:
+
+```php
+use Core\Mcp\Context\WorkspaceContext;
+
+// Get workspace context from request
+$context = WorkspaceContext::fromRequest($request);
+
+// Queries automatically filtered by workspace_id
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+], $context);
+
+// Internally becomes:
+// SELECT * FROM posts WHERE status = ? AND workspace_id = ?
+```
+
+### Validation
+
+Tools validate workspace context before execution:
+
+```php
+use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
+
+class MyTool
+{
+ use RequiresWorkspaceContext;
+
+ public function execute(array $params)
+ {
+ // Throws MissingWorkspaceContextException if context missing
+ $this->validateWorkspaceContext();
+
+ // Safe to proceed
+ $workspace = $this->workspaceContext->workspace;
+ }
+}
+```
+
+### Bypassing (Admin Only)
+
+```php
+// Requires admin permission
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ 'bypass_workspace_scope' => true, // Admin only
+]);
+```
+
+## Connection Security
+
+### Allowed Connections
+
+Only specific connections can be queried:
+
+```php
+// config/mcp.php
+return [
+ 'database' => [
+ 'allowed_connections' => [
+ 'mysql', // Primary database
+ 'analytics', // Read-only analytics
+ 'logs', // Application logs
+ ],
+ 'default_connection' => 'mysql',
+ ],
+];
+```
+
+### Read-Only Connections
+
+Use read-only database users for MCP:
+
+```php
+// config/database.php
+'connections' => [
+ 'mcp_readonly' => [
+ 'driver' => 'mysql',
+ 'host' => env('DB_HOST'),
+ 'database' => env('DB_DATABASE'),
+ 'username' => env('MCP_DB_USER'), // Read-only user
+ 'password' => env('MCP_DB_PASSWORD'),
+ 'charset' => 'utf8mb4',
+ ],
+],
+```
+
+**Database Setup:**
+
+```sql
+-- Create read-only user
+CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
+
+-- Grant SELECT only
+GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
+
+-- Explicitly deny modifications
+REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER ON app_database.* FROM 'mcp_readonly'@'%';
+
+FLUSH PRIVILEGES;
+```
+
+### Connection Validation
+
+```php
+use Core\Mcp\Services\ConnectionValidator;
+
+$validator = app(ConnectionValidator::class);
+
+// Check if connection is allowed
+if (!$validator->isAllowed('mysql')) {
+ throw new ForbiddenConnectionException();
+}
+
+// Check if connection exists
+if (!$validator->exists('mysql')) {
+ throw new InvalidConnectionException();
+}
+```
+
+## Rate Limiting
+
+Prevent abuse with rate limits:
+
+```php
+use Core\Mcp\Middleware\CheckMcpQuota;
+
+Route::middleware([CheckMcpQuota::class])
+ ->post('/mcp/query', [McpApiController::class, 'query']);
+```
+
+**Limits:**
+
+| Tier | Requests/Hour | Queries/Day |
+|------|--------------|-------------|
+| Free | 60 | 500 |
+| Pro | 600 | 10,000 |
+| Enterprise | Unlimited | Unlimited |
+
+### Quota Enforcement
+
+```php
+use Core\Mcp\Services\McpQuotaService;
+
+$quota = app(McpQuotaService::class);
+
+// Check if within quota
+if (!$quota->withinLimit($workspace)) {
+ throw new QuotaExceededException();
+}
+
+// Record usage
+$quota->recordUsage($workspace, 'query_database');
+```
+
+## Query Logging
+
+All queries are logged for audit:
+
+```php
+// storage/logs/mcp-queries.log
+[2026-01-26 12:00:00] Query executed
+ Workspace: acme-corp
+ User: john@example.com
+ Query: SELECT * FROM posts WHERE status = ?
+ Bindings: ["published"]
+ Rows: 42
+ Duration: 5.23ms
+```
+
+### Log Configuration
+
+```php
+// config/logging.php
+'channels' => [
+ 'mcp' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/mcp-queries.log'),
+ 'level' => 'info',
+ 'days' => 90, // Retain for 90 days
+ ],
+],
+```
+
+## Best Practices
+
+### 1. Always Use Bindings
+
+```php
+// ✅ Good - parameterized
+'query' => 'SELECT * FROM posts WHERE id = ?',
+'bindings' => [$id],
+
+// ❌ Bad - SQL injection risk
+'query' => "SELECT * FROM posts WHERE id = {$id}",
+```
+
+### 2. Limit Result Sets
+
+```php
+// ✅ Good - limited results
+'query' => 'SELECT * FROM posts LIMIT 100',
+
+// ❌ Bad - unbounded query
+'query' => 'SELECT * FROM posts',
+```
+
+### 3. Use Read-Only Connections
+
+```php
+// ✅ Good - read-only user
+'connection' => 'mcp_readonly',
+
+// ❌ Bad - admin connection
+'connection' => 'mysql_admin',
+```
+
+### 4. Validate Workspace Context
+
+```php
+// ✅ Good - validate context
+$this->validateWorkspaceContext();
+
+// ❌ Bad - no validation
+// (workspace boundary bypass risk)
+```
+
+## Testing
+
+```php
+use Tests\TestCase;
+use Core\Mcp\Services\SqlQueryValidator;
+
+class SecurityTest extends TestCase
+{
+ public function test_blocks_destructive_queries(): void
+ {
+ $validator = app(SqlQueryValidator::class);
+
+ $result = $validator->validate('DROP TABLE users');
+
+ $this->assertFalse($result['valid']);
+ $this->assertStringContainsString('Only SELECT', $result['error']);
+ }
+
+ public function test_allows_select_queries(): void
+ {
+ $validator = app(SqlQueryValidator::class);
+
+ $result = $validator->validate('SELECT * FROM posts WHERE id = ?');
+
+ $this->assertTrue($result['valid']);
+ }
+
+ public function test_enforces_workspace_scope(): void
+ {
+ $workspace = Workspace::factory()->create();
+ $context = new WorkspaceContext($workspace);
+
+ $result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ ], $context);
+
+ // Should only return workspace's posts
+ $this->assertEquals($workspace->id, $result['rows'][0]['workspace_id']);
+ }
+}
+```
+
+## Learn More
+
+- [Query Database →](/packages/mcp/query-database)
+- [Workspace Context →](/packages/mcp/workspace)
+- [Quotas →](/packages/mcp/quotas)
diff --git a/docs/packages/mcp/sql-security.md b/docs/packages/mcp/sql-security.md
new file mode 100644
index 0000000..fead675
--- /dev/null
+++ b/docs/packages/mcp/sql-security.md
@@ -0,0 +1,605 @@
+# Guide: SQL Security
+
+This guide documents the security controls for the Query Database MCP tool, including allowed SQL patterns, forbidden operations, and parameterized query requirements.
+
+## Overview
+
+The MCP Query Database tool provides AI agents with read-only SQL access. Multiple security layers protect against:
+
+- SQL injection attacks
+- Data modification/destruction
+- Cross-tenant data access
+- Resource exhaustion
+- Information leakage
+
+## Allowed SQL Patterns
+
+### SELECT-Only Queries
+
+Only `SELECT` statements are permitted. All queries must begin with `SELECT`:
+
+```sql
+-- Allowed: Basic SELECT
+SELECT * FROM posts WHERE status = 'published';
+
+-- Allowed: Specific columns
+SELECT id, title, created_at FROM posts;
+
+-- Allowed: COUNT queries
+SELECT COUNT(*) FROM users WHERE active = 1;
+
+-- Allowed: Aggregation
+SELECT status, COUNT(*) as count FROM posts GROUP BY status;
+
+-- Allowed: JOIN queries
+SELECT posts.title, users.name
+FROM posts
+JOIN users ON posts.user_id = users.id;
+
+-- Allowed: ORDER BY and LIMIT
+SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
+
+-- Allowed: WHERE with multiple conditions
+SELECT * FROM posts
+WHERE status = 'published'
+ AND user_id = 42
+ AND created_at > '2024-01-01';
+```
+
+### Supported Operators
+
+WHERE clauses support these operators:
+
+| Operator | Example |
+|----------|---------|
+| `=` | `WHERE status = 'active'` |
+| `!=`, `<>` | `WHERE status != 'deleted'` |
+| `>`, `>=` | `WHERE created_at > '2024-01-01'` |
+| `<`, `<=` | `WHERE views < 1000` |
+| `LIKE` | `WHERE title LIKE '%search%'` |
+| `IN` | `WHERE status IN ('draft', 'published')` |
+| `BETWEEN` | `WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'` |
+| `IS NULL` | `WHERE deleted_at IS NULL` |
+| `IS NOT NULL` | `WHERE email IS NOT NULL` |
+| `AND` | `WHERE a = 1 AND b = 2` |
+| `OR` | `WHERE status = 'draft' OR status = 'review'` |
+
+## Forbidden Operations
+
+### Data Modification (Blocked)
+
+```sql
+-- BLOCKED: INSERT
+INSERT INTO users (name) VALUES ('attacker');
+
+-- BLOCKED: UPDATE
+UPDATE users SET role = 'admin' WHERE id = 1;
+
+-- BLOCKED: DELETE
+DELETE FROM users WHERE id = 1;
+
+-- BLOCKED: REPLACE
+REPLACE INTO users (id, name) VALUES (1, 'changed');
+```
+
+### Schema Modification (Blocked)
+
+```sql
+-- BLOCKED: DROP
+DROP TABLE users;
+DROP DATABASE production;
+
+-- BLOCKED: TRUNCATE
+TRUNCATE TABLE logs;
+
+-- BLOCKED: ALTER
+ALTER TABLE users ADD COLUMN backdoor TEXT;
+
+-- BLOCKED: CREATE
+CREATE TABLE malicious_table (...);
+
+-- BLOCKED: RENAME
+RENAME TABLE users TO users_backup;
+```
+
+### Permission Operations (Blocked)
+
+```sql
+-- BLOCKED: GRANT
+GRANT ALL ON *.* TO 'attacker'@'%';
+
+-- BLOCKED: REVOKE
+REVOKE SELECT ON database.* FROM 'user'@'%';
+
+-- BLOCKED: FLUSH
+FLUSH PRIVILEGES;
+```
+
+### System Operations (Blocked)
+
+```sql
+-- BLOCKED: File operations
+SELECT * FROM posts INTO OUTFILE '/tmp/data.csv';
+SELECT LOAD_FILE('/etc/passwd');
+LOAD DATA INFILE '/etc/passwd' INTO TABLE users;
+
+-- BLOCKED: Execution
+EXECUTE prepared_statement;
+CALL stored_procedure();
+PREPARE stmt FROM 'SELECT ...';
+
+-- BLOCKED: Variables
+SET @var = (SELECT password FROM users);
+SET GLOBAL max_connections = 1;
+```
+
+### Complete Blocked Keywords List
+
+```php
+// Data modification
+'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'TRUNCATE'
+
+// Schema changes
+'DROP', 'ALTER', 'CREATE', 'RENAME'
+
+// Permissions
+'GRANT', 'REVOKE', 'FLUSH'
+
+// System
+'KILL', 'RESET', 'PURGE'
+
+// File operations
+'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE', 'LOAD DATA'
+
+// Execution
+'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'CALL'
+
+// Variables
+'SET '
+```
+
+## SQL Injection Prevention
+
+### Dangerous Patterns (Detected and Blocked)
+
+The validator detects and blocks common injection patterns:
+
+#### Stacked Queries
+
+```sql
+-- BLOCKED: Multiple statements
+SELECT * FROM posts; DROP TABLE users;
+SELECT * FROM posts; DELETE FROM logs;
+```
+
+#### UNION Injection
+
+```sql
+-- BLOCKED: UNION attacks
+SELECT * FROM posts WHERE id = 1 UNION SELECT password FROM users;
+SELECT * FROM posts UNION ALL SELECT * FROM secrets;
+```
+
+#### Comment Obfuscation
+
+```sql
+-- BLOCKED: Comments hiding keywords
+SELECT * FROM posts WHERE id = 1 /**/UNION/**/SELECT password FROM users;
+SELECT * FROM posts; -- DROP TABLE users
+SELECT * FROM posts # DELETE FROM logs
+```
+
+#### Hex Encoding
+
+```sql
+-- BLOCKED: Hex-encoded strings
+SELECT * FROM posts WHERE id = 0x313B44524F50205441424C4520757365727320;
+```
+
+#### Time-Based Attacks
+
+```sql
+-- BLOCKED: Timing attacks
+SELECT * FROM posts WHERE id = 1 AND SLEEP(10);
+SELECT * FROM posts WHERE BENCHMARK(10000000, SHA1('test'));
+```
+
+#### System Table Access
+
+```sql
+-- BLOCKED: Information schema
+SELECT * FROM information_schema.tables;
+SELECT * FROM information_schema.columns WHERE table_name = 'users';
+
+-- BLOCKED: MySQL system tables
+SELECT * FROM mysql.user;
+SELECT * FROM performance_schema.threads;
+SELECT * FROM sys.session;
+```
+
+#### Subquery in WHERE
+
+```sql
+-- BLOCKED: Potential data exfiltration
+SELECT * FROM posts WHERE id = (SELECT user_id FROM admins LIMIT 1);
+```
+
+### Detection Patterns
+
+The validator uses these regex patterns to detect attacks:
+
+```php
+// Stacked queries
+'/;\s*\S/i'
+
+// UNION injection
+'/\bUNION\b/i'
+
+// Hex encoding
+'/0x[0-9a-f]+/i'
+
+// Dangerous functions
+'/\bCHAR\s*\(/i'
+'/\bBENCHMARK\s*\(/i'
+'/\bSLEEP\s*\(/i'
+
+// System tables
+'/\bINFORMATION_SCHEMA\b/i'
+'/\bmysql\./i'
+'/\bperformance_schema\./i'
+'/\bsys\./i'
+
+// Subquery in WHERE
+'/WHERE\s+.*\(\s*SELECT/i'
+
+// Comment obfuscation
+'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i'
+```
+
+## Parameterized Queries
+
+**Always use parameter bindings** instead of string interpolation:
+
+### Correct Usage
+
+```php
+// SAFE: Parameterized query
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
+ 'bindings' => [$userId, 'published'],
+]);
+
+// SAFE: Multiple parameters
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM orders WHERE created_at BETWEEN ? AND ? AND total > ?',
+ 'bindings' => ['2024-01-01', '2024-12-31', 100.00],
+]);
+```
+
+### Incorrect Usage (Vulnerable)
+
+```php
+// VULNERABLE: String interpolation
+$result = $tool->execute([
+ 'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
+]);
+
+// VULNERABLE: Concatenation
+$query = "SELECT * FROM posts WHERE status = '" . $status . "'";
+$result = $tool->execute(['query' => $query]);
+
+// VULNERABLE: sprintf
+$query = sprintf("SELECT * FROM posts WHERE id = %d", $id);
+$result = $tool->execute(['query' => $query]);
+```
+
+### Why Bindings Matter
+
+With bindings, malicious input is escaped automatically:
+
+```php
+// User input
+$userInput = "'; DROP TABLE users; --";
+
+// With bindings: SAFE (input is escaped)
+$tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE title = ?',
+ 'bindings' => [$userInput],
+]);
+// Executed as: SELECT * FROM posts WHERE title = '\'; DROP TABLE users; --'
+
+// Without bindings: VULNERABLE
+$tool->execute([
+ 'query' => "SELECT * FROM posts WHERE title = '$userInput'",
+]);
+// Executed as: SELECT * FROM posts WHERE title = ''; DROP TABLE users; --'
+```
+
+## Whitelist-Based Validation
+
+The validator uses a whitelist approach, only allowing queries matching known-safe patterns:
+
+### Default Whitelist Patterns
+
+```php
+// Simple SELECT with optional WHERE
+'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?
+ (\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
+ (\s+ORDER\s+BY\s+[\w\s,`]+)?
+ (\s+LIMIT\s+\d+)?;?\s*$/i'
+
+// COUNT queries
+'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)
+ \s+FROM\s+`?\w+`?
+ (\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*;?\s*$/i'
+
+// Explicit column list
+'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*
+ \s+FROM\s+`?\w+`?
+ (\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
+ (\s+ORDER\s+BY\s+[\w\s,`]+)?
+ (\s+LIMIT\s+\d+)?;?\s*$/i'
+```
+
+### Adding Custom Patterns
+
+```php
+// config/mcp.php
+'database' => [
+ 'use_whitelist' => true,
+ 'whitelist_patterns' => [
+ // Allow specific JOIN pattern
+ '/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+posts\s+JOIN\s+users\s+ON\s+posts\.user_id\s*=\s*users\.id/i',
+ ],
+],
+```
+
+## Connection Security
+
+### Allowed Connections
+
+Only whitelisted database connections can be queried:
+
+```php
+// config/mcp.php
+'database' => [
+ 'allowed_connections' => [
+ 'mysql', // Primary database
+ 'analytics', // Read-only analytics
+ 'logs', // Application logs
+ ],
+ 'connection' => 'mcp_readonly', // Default MCP connection
+],
+```
+
+### Read-Only Database User
+
+Create a dedicated read-only user for MCP:
+
+```sql
+-- Create read-only user
+CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
+
+-- Grant SELECT only
+GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
+
+-- Explicitly deny write operations
+REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER
+ON app_database.* FROM 'mcp_readonly'@'%';
+
+FLUSH PRIVILEGES;
+```
+
+Configure in Laravel:
+
+```php
+// config/database.php
+'connections' => [
+ 'mcp_readonly' => [
+ 'driver' => 'mysql',
+ 'host' => env('DB_HOST'),
+ 'database' => env('DB_DATABASE'),
+ 'username' => env('MCP_DB_USER', 'mcp_readonly'),
+ 'password' => env('MCP_DB_PASSWORD'),
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+ 'strict' => true,
+ ],
+],
+```
+
+## Blocked Tables
+
+Configure tables that cannot be queried:
+
+```php
+// config/mcp.php
+'database' => [
+ 'blocked_tables' => [
+ 'users', // User credentials
+ 'password_resets', // Password tokens
+ 'sessions', // Session data
+ 'api_keys', // API credentials
+ 'oauth_access_tokens', // OAuth tokens
+ 'personal_access_tokens', // Sanctum tokens
+ 'failed_jobs', // Job queue data
+ ],
+],
+```
+
+The validator checks for table references in multiple formats:
+
+```php
+// All these are blocked for 'users' table:
+'SELECT * FROM users'
+'SELECT * FROM `users`'
+'SELECT posts.*, users.name FROM posts JOIN users...'
+'SELECT users.email FROM ...'
+```
+
+## Row Limits
+
+Automatic row limits prevent data exfiltration:
+
+```php
+// config/mcp.php
+'database' => [
+ 'max_rows' => 1000, // Maximum rows per query
+],
+```
+
+If query doesn't include LIMIT, one is added automatically:
+
+```php
+// Query without LIMIT
+$tool->execute(['query' => 'SELECT * FROM posts']);
+// Becomes: SELECT * FROM posts LIMIT 1000
+
+// Query with smaller LIMIT (preserved)
+$tool->execute(['query' => 'SELECT * FROM posts LIMIT 10']);
+// Stays: SELECT * FROM posts LIMIT 10
+```
+
+## Error Handling
+
+### Forbidden Query Response
+
+```json
+{
+ "error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
+}
+```
+
+### Invalid Structure Response
+
+```json
+{
+ "error": "Query rejected: Query must begin with SELECT"
+}
+```
+
+### Not Whitelisted Response
+
+```json
+{
+ "error": "Query rejected: Query does not match any allowed pattern"
+}
+```
+
+### Sanitized SQL Errors
+
+Database errors are sanitized to prevent information leakage:
+
+```php
+// Original error (logged for debugging)
+"SQLSTATE[42S02]: Table 'production.secret_table' doesn't exist at 192.168.1.100"
+
+// Sanitized response (returned to client)
+"Query execution failed: Table '[path]' doesn't exist at [ip]"
+```
+
+## Configuration Reference
+
+```php
+// config/mcp.php
+return [
+ 'database' => [
+ // Database connection for MCP queries
+ 'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
+
+ // Use whitelist validation (recommended: true)
+ 'use_whitelist' => true,
+
+ // Custom whitelist patterns (regex)
+ 'whitelist_patterns' => [],
+
+ // Tables that cannot be queried
+ 'blocked_tables' => [
+ 'users',
+ 'password_resets',
+ 'sessions',
+ 'api_keys',
+ ],
+
+ // Maximum rows per query
+ 'max_rows' => 1000,
+
+ // Query execution timeout (milliseconds)
+ 'timeout' => 5000,
+
+ // Enable EXPLAIN analysis
+ 'enable_explain' => true,
+ ],
+];
+```
+
+## Testing Security
+
+```php
+use Tests\TestCase;
+use Core\Mod\Mcp\Services\SqlQueryValidator;
+use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
+
+class SqlSecurityTest extends TestCase
+{
+ private SqlQueryValidator $validator;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->validator = new SqlQueryValidator();
+ }
+
+ public function test_blocks_delete(): void
+ {
+ $this->expectException(ForbiddenQueryException::class);
+ $this->validator->validate('DELETE FROM users');
+ }
+
+ public function test_blocks_union_injection(): void
+ {
+ $this->expectException(ForbiddenQueryException::class);
+ $this->validator->validate("SELECT * FROM posts UNION SELECT password FROM users");
+ }
+
+ public function test_blocks_stacked_queries(): void
+ {
+ $this->expectException(ForbiddenQueryException::class);
+ $this->validator->validate("SELECT * FROM posts; DROP TABLE users");
+ }
+
+ public function test_blocks_system_tables(): void
+ {
+ $this->expectException(ForbiddenQueryException::class);
+ $this->validator->validate("SELECT * FROM information_schema.tables");
+ }
+
+ public function test_allows_safe_select(): void
+ {
+ $this->validator->validate("SELECT id, title FROM posts WHERE status = 'published'");
+ $this->assertTrue(true); // No exception = pass
+ }
+
+ public function test_allows_count(): void
+ {
+ $this->validator->validate("SELECT COUNT(*) FROM posts");
+ $this->assertTrue(true);
+ }
+}
+```
+
+## Best Practices Summary
+
+1. **Always use parameterized queries** - Never interpolate values into SQL strings
+2. **Use a read-only database user** - Database-level protection against modifications
+3. **Configure blocked tables** - Prevent access to sensitive data
+4. **Enable whitelist validation** - Only allow known-safe query patterns
+5. **Set appropriate row limits** - Prevent large data exports
+6. **Review logs regularly** - Monitor for suspicious query patterns
+7. **Test security controls** - Include injection tests in your test suite
+
+## Learn More
+
+- [Query Database Tool](/packages/mcp/query-database) - Tool usage
+- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
+- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Tool development
diff --git a/docs/packages/mcp/tools-reference.md b/docs/packages/mcp/tools-reference.md
new file mode 100644
index 0000000..1dd1d52
--- /dev/null
+++ b/docs/packages/mcp/tools-reference.md
@@ -0,0 +1,739 @@
+# API Reference: MCP Tools
+
+Complete reference for all MCP tools including parameters, response formats, and error handling.
+
+## Database Tools
+
+### query_database
+
+Execute read-only SQL queries against the database.
+
+**Description:** Execute a read-only SQL SELECT query against the database
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `query` | string | Yes | SQL SELECT query to execute. Only read-only SELECT queries are permitted. |
+| `explain` | boolean | No | If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization. Default: `false` |
+
+**Example Request:**
+
+```json
+{
+ "tool": "query_database",
+ "arguments": {
+ "query": "SELECT id, title, status FROM posts WHERE status = 'published' LIMIT 10"
+ }
+}
+```
+
+**Success Response:**
+
+```json
+[
+ {"id": 1, "title": "First Post", "status": "published"},
+ {"id": 2, "title": "Second Post", "status": "published"}
+]
+```
+
+**With EXPLAIN:**
+
+```json
+{
+ "tool": "query_database",
+ "arguments": {
+ "query": "SELECT * FROM posts WHERE status = 'published'",
+ "explain": true
+ }
+}
+```
+
+**EXPLAIN Response:**
+
+```json
+{
+ "explain": [
+ {
+ "id": 1,
+ "select_type": "SIMPLE",
+ "table": "posts",
+ "type": "ref",
+ "key": "idx_status",
+ "rows": 150,
+ "Extra": "Using index"
+ }
+ ],
+ "query": "SELECT * FROM posts WHERE status = 'published' LIMIT 1000",
+ "interpretation": [
+ {
+ "table": "posts",
+ "analysis": [
+ "GOOD: Using index: idx_status"
+ ]
+ }
+ ]
+}
+```
+
+**Error Response - Forbidden Query:**
+
+```json
+{
+ "error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
+}
+```
+
+**Error Response - Invalid Structure:**
+
+```json
+{
+ "error": "Query rejected: Query must begin with SELECT"
+}
+```
+
+**Security Notes:**
+- Only SELECT queries are allowed
+- Blocked keywords: INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE
+- UNION queries are blocked
+- System tables (information_schema, mysql.*) are blocked
+- Automatic LIMIT applied if not specified
+- Use read-only database connection
+
+---
+
+### list_tables
+
+List all database tables in the application.
+
+**Description:** List all database tables
+
+**Parameters:** None
+
+**Example Request:**
+
+```json
+{
+ "tool": "list_tables",
+ "arguments": {}
+}
+```
+
+**Success Response:**
+
+```json
+[
+ "users",
+ "posts",
+ "comments",
+ "tags",
+ "categories",
+ "media",
+ "migrations",
+ "jobs"
+]
+```
+
+**Security Notes:**
+- Returns table names only, not structure
+- Some tables may be filtered based on configuration
+
+---
+
+## Commerce Tools
+
+### get_billing_status
+
+Get billing status for the authenticated workspace.
+
+**Description:** Get billing status for your workspace including subscription, current plan, and billing period
+
+**Parameters:** None (workspace from authentication context)
+
+**Requires:** Workspace Context
+
+**Example Request:**
+
+```json
+{
+ "tool": "get_billing_status",
+ "arguments": {}
+}
+```
+
+**Success Response:**
+
+```json
+{
+ "workspace": {
+ "id": 42,
+ "name": "Acme Corp"
+ },
+ "subscription": {
+ "id": 123,
+ "status": "active",
+ "gateway": "stripe",
+ "billing_cycle": "monthly",
+ "current_period_start": "2024-01-01T00:00:00+00:00",
+ "current_period_end": "2024-02-01T00:00:00+00:00",
+ "days_until_renewal": 15,
+ "cancel_at_period_end": false,
+ "on_trial": false,
+ "trial_ends_at": null
+ },
+ "packages": [
+ {
+ "code": "professional",
+ "name": "Professional Plan",
+ "status": "active",
+ "expires_at": null
+ }
+ ]
+}
+```
+
+**Response Fields:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `workspace.id` | integer | Workspace ID |
+| `workspace.name` | string | Workspace name |
+| `subscription.status` | string | active, trialing, past_due, canceled |
+| `subscription.billing_cycle` | string | monthly, yearly |
+| `subscription.days_until_renewal` | integer | Days until next billing |
+| `subscription.on_trial` | boolean | Currently in trial period |
+| `packages` | array | Active feature packages |
+
+**Error Response - No Workspace Context:**
+
+```json
+{
+ "error": "MCP tool 'get_billing_status' requires workspace context. Authenticate with an API key or user session."
+}
+```
+
+---
+
+### list_invoices
+
+List invoices for the authenticated workspace.
+
+**Description:** List invoices for your workspace with optional status filter
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `status` | string | No | Filter by status: paid, pending, overdue, void |
+| `limit` | integer | No | Maximum invoices to return. Default: 10, Max: 50 |
+
+**Requires:** Workspace Context
+
+**Example Request:**
+
+```json
+{
+ "tool": "list_invoices",
+ "arguments": {
+ "status": "paid",
+ "limit": 5
+ }
+}
+```
+
+**Success Response:**
+
+```json
+{
+ "workspace_id": 42,
+ "count": 5,
+ "invoices": [
+ {
+ "id": 1001,
+ "invoice_number": "INV-2024-001",
+ "status": "paid",
+ "subtotal": 99.00,
+ "discount_amount": 0.00,
+ "tax_amount": 19.80,
+ "total": 118.80,
+ "amount_paid": 118.80,
+ "amount_due": 0.00,
+ "currency": "GBP",
+ "issue_date": "2024-01-01",
+ "due_date": "2024-01-15",
+ "paid_at": "2024-01-10T14:30:00+00:00",
+ "is_overdue": false,
+ "order_number": "ORD-2024-001"
+ }
+ ]
+}
+```
+
+**Response Fields:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `invoice_number` | string | Unique invoice identifier |
+| `status` | string | paid, pending, overdue, void |
+| `total` | number | Total amount including tax |
+| `amount_due` | number | Remaining amount to pay |
+| `is_overdue` | boolean | Past due date with unpaid balance |
+
+---
+
+### upgrade_plan
+
+Preview or execute a plan upgrade/downgrade.
+
+**Description:** Preview or execute a plan upgrade/downgrade for your workspace subscription
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `package_code` | string | Yes | Code of the new package (e.g., agency, enterprise) |
+| `preview` | boolean | No | If true, only preview without executing. Default: `true` |
+| `immediate` | boolean | No | If true, apply immediately; false schedules for period end. Default: `true` |
+
+**Requires:** Workspace Context
+
+**Example Request - Preview:**
+
+```json
+{
+ "tool": "upgrade_plan",
+ "arguments": {
+ "package_code": "enterprise",
+ "preview": true
+ }
+}
+```
+
+**Preview Response:**
+
+```json
+{
+ "preview": true,
+ "current_package": "professional",
+ "new_package": "enterprise",
+ "proration": {
+ "is_upgrade": true,
+ "is_downgrade": false,
+ "current_plan_price": 99.00,
+ "new_plan_price": 299.00,
+ "credit_amount": 49.50,
+ "prorated_new_cost": 149.50,
+ "net_amount": 100.00,
+ "requires_payment": true,
+ "days_remaining": 15,
+ "currency": "GBP"
+ }
+}
+```
+
+**Execute Response:**
+
+```json
+{
+ "success": true,
+ "immediate": true,
+ "current_package": "professional",
+ "new_package": "enterprise",
+ "proration": {
+ "is_upgrade": true,
+ "net_amount": 100.00
+ },
+ "subscription_status": "active"
+}
+```
+
+**Error Response - Package Not Found:**
+
+```json
+{
+ "error": "Package not found",
+ "available_packages": ["starter", "professional", "agency", "enterprise"]
+}
+```
+
+---
+
+### create_coupon
+
+Create a new discount coupon code.
+
+**Description:** Create a new discount coupon code
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `code` | string | Yes | Unique coupon code (uppercase letters, numbers, hyphens, underscores) |
+| `name` | string | Yes | Display name for the coupon |
+| `type` | string | No | Discount type: percentage or fixed_amount. Default: percentage |
+| `value` | number | Yes | Discount value (1-100 for percentage, or fixed amount) |
+| `duration` | string | No | How long discount applies: once, repeating, forever. Default: once |
+| `max_uses` | integer | No | Maximum total uses (null for unlimited) |
+| `valid_until` | string | No | Expiry date in YYYY-MM-DD format |
+
+**Example Request:**
+
+```json
+{
+ "tool": "create_coupon",
+ "arguments": {
+ "code": "SUMMER25",
+ "name": "Summer Sale 2024",
+ "type": "percentage",
+ "value": 25,
+ "duration": "once",
+ "max_uses": 100,
+ "valid_until": "2024-08-31"
+ }
+}
+```
+
+**Success Response:**
+
+```json
+{
+ "success": true,
+ "coupon": {
+ "id": 42,
+ "code": "SUMMER25",
+ "name": "Summer Sale 2024",
+ "type": "percentage",
+ "value": 25.0,
+ "duration": "once",
+ "max_uses": 100,
+ "valid_until": "2024-08-31",
+ "is_active": true
+ }
+}
+```
+
+**Error Response - Invalid Code:**
+
+```json
+{
+ "error": "Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores."
+}
+```
+
+**Error Response - Duplicate Code:**
+
+```json
+{
+ "error": "A coupon with this code already exists."
+}
+```
+
+**Error Response - Invalid Percentage:**
+
+```json
+{
+ "error": "Percentage value must be between 1 and 100."
+}
+```
+
+---
+
+## System Tools
+
+### list_sites
+
+List all sites managed by the platform.
+
+**Description:** List all sites managed by Host Hub
+
+**Parameters:** None
+
+**Example Request:**
+
+```json
+{
+ "tool": "list_sites",
+ "arguments": {}
+}
+```
+
+**Success Response:**
+
+```json
+[
+ {
+ "name": "BioHost",
+ "domain": "link.host.uk.com",
+ "type": "WordPress"
+ },
+ {
+ "name": "SocialHost",
+ "domain": "social.host.uk.com",
+ "type": "Laravel"
+ },
+ {
+ "name": "AnalyticsHost",
+ "domain": "analytics.host.uk.com",
+ "type": "Node.js"
+ }
+]
+```
+
+---
+
+### list_routes
+
+List all web routes in the application.
+
+**Description:** List all web routes in the application
+
+**Parameters:** None
+
+**Example Request:**
+
+```json
+{
+ "tool": "list_routes",
+ "arguments": {}
+}
+```
+
+**Success Response:**
+
+```json
+[
+ {
+ "uri": "/",
+ "methods": ["GET", "HEAD"],
+ "name": "home"
+ },
+ {
+ "uri": "/login",
+ "methods": ["GET", "HEAD"],
+ "name": "login"
+ },
+ {
+ "uri": "/api/posts",
+ "methods": ["GET", "HEAD"],
+ "name": "api.posts.index"
+ },
+ {
+ "uri": "/api/posts/{post}",
+ "methods": ["GET", "HEAD"],
+ "name": "api.posts.show"
+ }
+]
+```
+
+---
+
+### get_stats
+
+Get current system statistics.
+
+**Description:** Get current system statistics for Host Hub
+
+**Parameters:** None
+
+**Example Request:**
+
+```json
+{
+ "tool": "get_stats",
+ "arguments": {}
+}
+```
+
+**Success Response:**
+
+```json
+{
+ "total_sites": 6,
+ "active_users": 128,
+ "page_views_30d": 12500,
+ "server_load": "23%"
+}
+```
+
+---
+
+## Common Error Responses
+
+### Missing Workspace Context
+
+Tools requiring workspace context return this when no API key or session is provided:
+
+```json
+{
+ "error": "MCP tool 'tool_name' requires workspace context. Authenticate with an API key or user session."
+}
+```
+
+**HTTP Status:** 403
+
+### Missing Dependency
+
+When a tool's dependencies aren't satisfied:
+
+```json
+{
+ "error": "dependency_not_met",
+ "message": "Dependencies not satisfied for tool 'update_task'",
+ "missing": [
+ {
+ "type": "tool_called",
+ "key": "create_plan",
+ "description": "A plan must be created before updating tasks"
+ }
+ ],
+ "suggested_order": ["create_plan", "update_task"]
+}
+```
+
+**HTTP Status:** 422
+
+### Quota Exceeded
+
+When workspace has exceeded their tool usage quota:
+
+```json
+{
+ "error": "quota_exceeded",
+ "message": "Daily tool quota exceeded for this workspace",
+ "current_usage": 1000,
+ "limit": 1000,
+ "resets_at": "2024-01-16T00:00:00+00:00"
+}
+```
+
+**HTTP Status:** 429
+
+### Validation Error
+
+When parameters fail validation:
+
+```json
+{
+ "error": "Validation failed",
+ "code": "VALIDATION_ERROR",
+ "details": {
+ "query": ["The query field is required"]
+ }
+}
+```
+
+**HTTP Status:** 422
+
+### Internal Error
+
+When an unexpected error occurs:
+
+```json
+{
+ "error": "An unexpected error occurred. Please try again.",
+ "code": "INTERNAL_ERROR"
+}
+```
+
+**HTTP Status:** 500
+
+---
+
+## Authentication
+
+### API Key Authentication
+
+Include your API key in the Authorization header:
+
+```bash
+curl -X POST https://api.example.com/mcp/tools/call \
+ -H "Authorization: Bearer sk_live_xxxxx" \
+ -H "Content-Type: application/json" \
+ -d '{"tool": "get_billing_status", "arguments": {}}'
+```
+
+### Session Authentication
+
+For browser-based access, use session cookies:
+
+```javascript
+fetch('/mcp/tools/call', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
+ },
+ body: JSON.stringify({
+ tool: 'list_invoices',
+ arguments: { limit: 10 }
+ })
+});
+```
+
+### MCP Session ID
+
+For tracking dependencies across tool calls, include a session ID:
+
+```bash
+curl -X POST https://api.example.com/mcp/tools/call \
+ -H "Authorization: Bearer sk_live_xxxxx" \
+ -H "X-MCP-Session-ID: session_abc123" \
+ -H "Content-Type: application/json" \
+ -d '{"tool": "update_task", "arguments": {...}}'
+```
+
+---
+
+## Tool Categories
+
+### Query Tools
+- `query_database` - Execute SQL queries
+- `list_tables` - List database tables
+
+### Commerce Tools
+- `get_billing_status` - Get subscription status
+- `list_invoices` - List workspace invoices
+- `upgrade_plan` - Change subscription plan
+- `create_coupon` - Create discount codes
+
+### System Tools
+- `list_sites` - List managed sites
+- `list_routes` - List application routes
+- `get_stats` - Get system statistics
+
+---
+
+## Response Format
+
+All tools return JSON responses. Success responses vary by tool, but error responses follow a consistent format:
+
+```json
+{
+ "error": "Human-readable error message",
+ "code": "ERROR_CODE",
+ "details": {} // Optional additional information
+}
+```
+
+**Common Error Codes:**
+
+| Code | Description |
+|------|-------------|
+| `VALIDATION_ERROR` | Invalid parameters |
+| `FORBIDDEN_QUERY` | SQL query blocked by security |
+| `MISSING_WORKSPACE_CONTEXT` | Workspace authentication required |
+| `QUOTA_EXCEEDED` | Usage limit reached |
+| `NOT_FOUND` | Resource not found |
+| `DEPENDENCY_NOT_MET` | Tool prerequisites not satisfied |
+| `INTERNAL_ERROR` | Unexpected server error |
+
+---
+
+## Learn More
+
+- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Build custom tools
+- [SQL Security](/packages/mcp/sql-security) - Query security rules
+- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
+- [Quotas](/packages/mcp/quotas) - Usage limits
+- [Analytics](/packages/mcp/analytics) - Usage tracking
diff --git a/docs/packages/mcp/tools.md b/docs/packages/mcp/tools.md
new file mode 100644
index 0000000..d9cd02b
--- /dev/null
+++ b/docs/packages/mcp/tools.md
@@ -0,0 +1,569 @@
+# Creating MCP Tools
+
+Learn how to create custom MCP tools for AI agents with parameter validation, dependency management, and workspace context.
+
+## Tool Structure
+
+Every MCP tool extends `BaseTool`:
+
+```php
+ [
+ 'type' => 'string',
+ 'description' => 'Filter by status',
+ 'enum' => ['published', 'draft', 'archived'],
+ 'required' => false,
+ ],
+ 'limit' => [
+ 'type' => 'integer',
+ 'description' => 'Number of posts to return',
+ 'default' => 10,
+ 'min' => 1,
+ 'max' => 100,
+ 'required' => false,
+ ],
+ ];
+ }
+
+ public function execute(array $params): array
+ {
+ $query = Post::query();
+
+ if (isset($params['status'])) {
+ $query->where('status', $params['status']);
+ }
+
+ $posts = $query->limit($params['limit'] ?? 10)->get();
+
+ return [
+ 'success' => true,
+ 'posts' => $posts->map(fn ($post) => [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ 'slug' => $post->slug,
+ 'status' => $post->status,
+ 'created_at' => $post->created_at->toIso8601String(),
+ ])->toArray(),
+ 'count' => $posts->count(),
+ ];
+ }
+}
+```
+
+## Registering Tools
+
+Register tools in your module's `Boot.php`:
+
+```php
+ 'onMcpTools',
+ ];
+
+ public function onMcpTools(McpToolsRegistering $event): void
+ {
+ $event->tool('blog:list-posts', ListPostsTool::class);
+ $event->tool('blog:create-post', CreatePostTool::class);
+ $event->tool('blog:get-post', GetPostTool::class);
+ }
+}
+```
+
+## Parameter Validation
+
+### Parameter Types
+
+```php
+public function getParameters(): array
+{
+ return [
+ // String
+ 'title' => [
+ 'type' => 'string',
+ 'description' => 'Post title',
+ 'minLength' => 1,
+ 'maxLength' => 255,
+ 'required' => true,
+ ],
+
+ // Integer
+ 'views' => [
+ 'type' => 'integer',
+ 'description' => 'Number of views',
+ 'min' => 0,
+ 'max' => 1000000,
+ 'required' => false,
+ ],
+
+ // Boolean
+ 'published' => [
+ 'type' => 'boolean',
+ 'description' => 'Is published',
+ 'required' => false,
+ ],
+
+ // Enum
+ 'status' => [
+ 'type' => 'string',
+ 'enum' => ['draft', 'published', 'archived'],
+ 'description' => 'Post status',
+ 'required' => true,
+ ],
+
+ // Array
+ 'tags' => [
+ 'type' => 'array',
+ 'description' => 'Post tags',
+ 'items' => ['type' => 'string'],
+ 'required' => false,
+ ],
+
+ // Object
+ 'metadata' => [
+ 'type' => 'object',
+ 'description' => 'Additional metadata',
+ 'properties' => [
+ 'featured' => ['type' => 'boolean'],
+ 'views' => ['type' => 'integer'],
+ ],
+ 'required' => false,
+ ],
+ ];
+}
+```
+
+### Default Values
+
+```php
+'limit' => [
+ 'type' => 'integer',
+ 'default' => 10, // Used if not provided
+ 'required' => false,
+]
+```
+
+### Custom Validation
+
+```php
+public function execute(array $params): array
+{
+ // Additional validation
+ if (isset($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
+ return [
+ 'success' => false,
+ 'error' => 'Invalid email address',
+ 'code' => 'INVALID_EMAIL',
+ ];
+ }
+
+ // Tool logic...
+}
+```
+
+## Workspace Context
+
+### Requiring Workspace
+
+Use the `RequiresWorkspaceContext` trait:
+
+```php
+getWorkspaceContext();
+
+ $post = Post::create([
+ 'title' => $params['title'],
+ 'content' => $params['content'],
+ 'workspace_id' => $workspace->id,
+ ]);
+
+ return [
+ 'success' => true,
+ 'post_id' => $post->id,
+ ];
+ }
+}
+```
+
+### Optional Workspace
+
+```php
+public function execute(array $params): array
+{
+ $workspace = $this->getWorkspaceContext(); // May be null
+
+ $query = Post::query();
+
+ if ($workspace) {
+ $query->where('workspace_id', $workspace->id);
+ }
+
+ return ['posts' => $query->get()];
+}
+```
+
+## Tool Dependencies
+
+### Declaring Dependencies
+
+```php
+ true,
+ 'data' => $result,
+ ];
+
+ } catch (\Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ 'code' => 'TOOL_EXECUTION_FAILED',
+ ];
+ }
+}
+```
+
+### Specific Error Codes
+
+```php
+// Validation error
+return [
+ 'success' => false,
+ 'error' => 'Title is required',
+ 'code' => 'VALIDATION_ERROR',
+ 'field' => 'title',
+];
+
+// Not found
+return [
+ 'success' => false,
+ 'error' => 'Post not found',
+ 'code' => 'NOT_FOUND',
+ 'resource_id' => $params['id'],
+];
+
+// Forbidden
+return [
+ 'success' => false,
+ 'error' => 'Insufficient permissions',
+ 'code' => 'FORBIDDEN',
+ 'required_permission' => 'posts.create',
+];
+```
+
+## Advanced Patterns
+
+### Tool with File Processing
+
+```php
+public function execute(array $params): array
+{
+ $csvPath = $params['csv_path'];
+
+ if (!file_exists($csvPath)) {
+ return [
+ 'success' => false,
+ 'error' => 'CSV file not found',
+ 'code' => 'FILE_NOT_FOUND',
+ ];
+ }
+
+ $imported = 0;
+ $errors = [];
+
+ if (($handle = fopen($csvPath, 'r')) !== false) {
+ while (($data = fgetcsv($handle)) !== false) {
+ try {
+ Post::create([
+ 'title' => $data[0],
+ 'content' => $data[1],
+ ]);
+ $imported++;
+ } catch (\Exception $e) {
+ $errors[] = "Row {$imported}: {$e->getMessage()}";
+ }
+ }
+ fclose($handle);
+ }
+
+ return [
+ 'success' => true,
+ 'imported' => $imported,
+ 'errors' => $errors,
+ ];
+}
+```
+
+### Tool with Pagination
+
+```php
+public function execute(array $params): array
+{
+ $page = $params['page'] ?? 1;
+ $perPage = $params['per_page'] ?? 15;
+
+ $posts = Post::paginate($perPage, ['*'], 'page', $page);
+
+ return [
+ 'success' => true,
+ 'posts' => $posts->items(),
+ 'pagination' => [
+ 'current_page' => $posts->currentPage(),
+ 'last_page' => $posts->lastPage(),
+ 'per_page' => $posts->perPage(),
+ 'total' => $posts->total(),
+ ],
+ ];
+}
+```
+
+### Tool with Progress Tracking
+
+```php
+public function execute(array $params): array
+{
+ $postIds = $params['post_ids'];
+ $total = count($postIds);
+ $processed = 0;
+
+ foreach ($postIds as $postId) {
+ $post = Post::find($postId);
+
+ if ($post) {
+ $post->publish();
+ $processed++;
+
+ // Emit progress event
+ event(new ToolProgress(
+ tool: $this->getName(),
+ progress: ($processed / $total) * 100,
+ message: "Published post {$postId}"
+ ));
+ }
+ }
+
+ return [
+ 'success' => true,
+ 'processed' => $processed,
+ 'total' => $total,
+ ];
+}
+```
+
+## Testing Tools
+
+```php
+count(5)->create();
+
+ $tool = new ListPostsTool();
+
+ $result = $tool->execute([]);
+
+ $this->assertTrue($result['success']);
+ $this->assertCount(5, $result['posts']);
+ }
+
+ public function test_filters_by_status(): void
+ {
+ Post::factory()->count(3)->create(['status' => 'published']);
+ Post::factory()->count(2)->create(['status' => 'draft']);
+
+ $tool = new ListPostsTool();
+
+ $result = $tool->execute([
+ 'status' => 'published',
+ ]);
+
+ $this->assertCount(3, $result['posts']);
+ }
+
+ public function test_respects_limit(): void
+ {
+ Post::factory()->count(20)->create();
+
+ $tool = new ListPostsTool();
+
+ $result = $tool->execute([
+ 'limit' => 5,
+ ]);
+
+ $this->assertCount(5, $result['posts']);
+ }
+}
+```
+
+## Best Practices
+
+### 1. Clear Naming
+
+```php
+// ✅ Good - descriptive name
+'blog:create-post'
+'blog:list-published-posts'
+'blog:delete-post'
+
+// ❌ Bad - vague name
+'blog:action'
+'do-thing'
+```
+
+### 2. Detailed Descriptions
+
+```php
+// ✅ Good - explains what and why
+public function getDescription(): string
+{
+ return 'Create a new blog post with title, content, and optional metadata. '
+ . 'Requires workspace context. Validates entitlements before creation.';
+}
+
+// ❌ Bad - too brief
+public function getDescription(): string
+{
+ return 'Creates post';
+}
+```
+
+### 3. Validate Parameters
+
+```php
+// ✅ Good - strict validation
+public function getParameters(): array
+{
+ return [
+ 'title' => [
+ 'type' => 'string',
+ 'required' => true,
+ 'minLength' => 1,
+ 'maxLength' => 255,
+ ],
+ ];
+}
+```
+
+### 4. Return Consistent Format
+
+```php
+// ✅ Good - always includes success
+return [
+ 'success' => true,
+ 'data' => $result,
+];
+
+return [
+ 'success' => false,
+ 'error' => $message,
+ 'code' => $code,
+];
+```
+
+## Learn More
+
+- [Query Database →](/packages/mcp/query-database)
+- [Workspace Context →](/packages/mcp/workspace)
+- [Tool Analytics →](/packages/mcp/analytics)
diff --git a/docs/packages/mcp/workspace.md b/docs/packages/mcp/workspace.md
new file mode 100644
index 0000000..0736654
--- /dev/null
+++ b/docs/packages/mcp/workspace.md
@@ -0,0 +1,368 @@
+# Workspace Context
+
+Workspace isolation and context resolution for MCP tools.
+
+## Overview
+
+Workspace context ensures that MCP tools operate within the correct workspace boundary, preventing data leaks and unauthorized access.
+
+## Context Resolution
+
+### From Request Headers
+
+```php
+use Core\Mcp\Context\WorkspaceContext;
+
+// Resolve from X-Workspace-ID header
+$context = WorkspaceContext::fromRequest($request);
+
+// Returns WorkspaceContext with:
+// - workspace: Workspace model
+// - user: Current user
+// - namespace: Current namespace (if applicable)
+```
+
+**Request Example:**
+
+```bash
+curl -H "Authorization: Bearer sk_live_..." \
+ -H "X-Workspace-ID: ws_abc123" \
+ https://api.example.com/mcp/query
+```
+
+### From API Key
+
+```php
+use Mod\Api\Models\ApiKey;
+
+$apiKey = ApiKey::findByKey($providedKey);
+
+// API key is scoped to workspace
+$context = WorkspaceContext::fromApiKey($apiKey);
+```
+
+### Manual Creation
+
+```php
+use Mod\Tenant\Models\Workspace;
+
+$workspace = Workspace::find($id);
+
+$context = new WorkspaceContext(
+ workspace: $workspace,
+ user: $user,
+ namespace: $namespace
+);
+```
+
+## Requiring Context
+
+### Tool Implementation
+
+```php
+validateWorkspaceContext();
+
+ // Access workspace
+ $workspace = $this->workspaceContext->workspace;
+
+ // Query scoped to workspace
+ return Post::where('workspace_id', $workspace->id)
+ ->where('status', $params['status'] ?? 'published')
+ ->get()
+ ->toArray();
+ }
+}
+```
+
+### Middleware
+
+```php
+use Core\Mcp\Middleware\ValidateWorkspaceContext;
+
+Route::middleware([ValidateWorkspaceContext::class])
+ ->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
+```
+
+**Validation:**
+- Header `X-Workspace-ID` is present
+- Workspace exists
+- User has access to workspace
+- API key is scoped to workspace
+
+## Automatic Query Scoping
+
+### SELECT Queries
+
+```php
+// Query without workspace filter
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts WHERE status = ?',
+ 'bindings' => ['published'],
+]);
+
+// Automatically becomes:
+// SELECT * FROM posts
+// WHERE status = ?
+// AND workspace_id = ?
+// With bindings: ['published', $workspaceId]
+```
+
+### BelongsToWorkspace Models
+
+```php
+use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
+
+class Post extends Model
+{
+ use BelongsToWorkspace;
+
+ // Automatically scoped to workspace
+}
+
+// All queries automatically filtered:
+Post::all(); // Only current workspace's posts
+Post::where('status', 'published')->get(); // Scoped
+Post::find($id); // Returns null if wrong workspace
+```
+
+## Context Properties
+
+### Workspace
+
+```php
+$workspace = $context->workspace;
+
+$workspace->id; // Workspace ID
+$workspace->name; // Workspace name
+$workspace->slug; // URL slug
+$workspace->settings; // Workspace settings
+$workspace->subscription; // Subscription plan
+```
+
+### User
+
+```php
+$user = $context->user;
+
+$user->id; // User ID
+$user->name; // User name
+$user->email; // User email
+$user->workspace_id; // Primary workspace
+$user->permissions; // User permissions
+```
+
+### Namespace
+
+```php
+$namespace = $context->namespace;
+
+if ($namespace) {
+ $namespace->id; // Namespace ID
+ $namespace->name; // Namespace name
+ $namespace->entitlements; // Feature access
+}
+```
+
+## Multi-Workspace Access
+
+### Switching Context
+
+```php
+// User with access to multiple workspaces
+$workspaces = $user->workspaces;
+
+foreach ($workspaces as $workspace) {
+ $context = new WorkspaceContext($workspace, $user);
+
+ // Execute in workspace context
+ $result = $tool->execute($params, $context);
+}
+```
+
+### Cross-Workspace Queries (Admin)
+
+```php
+// Requires admin permission
+$result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ 'bypass_workspace_scope' => true,
+], $context);
+
+// Returns posts from all workspaces
+```
+
+## Error Handling
+
+### Missing Context
+
+```php
+use Core\Mcp\Exceptions\MissingWorkspaceContextException;
+
+try {
+ $tool->execute($params); // No context provided
+} catch (MissingWorkspaceContextException $e) {
+ return response()->json([
+ 'error' => 'Workspace context required',
+ 'message' => 'Please provide X-Workspace-ID header',
+ ], 400);
+}
+```
+
+### Invalid Workspace
+
+```php
+use Core\Mod\Tenant\Exceptions\WorkspaceNotFoundException;
+
+try {
+ $context = WorkspaceContext::fromRequest($request);
+} catch (WorkspaceNotFoundException $e) {
+ return response()->json([
+ 'error' => 'Invalid workspace',
+ 'message' => 'Workspace not found',
+ ], 404);
+}
+```
+
+### Unauthorized Access
+
+```php
+use Illuminate\Auth\Access\AuthorizationException;
+
+try {
+ $context = WorkspaceContext::fromRequest($request);
+} catch (AuthorizationException $e) {
+ return response()->json([
+ 'error' => 'Unauthorized',
+ 'message' => 'You do not have access to this workspace',
+ ], 403);
+}
+```
+
+## Testing
+
+```php
+use Tests\TestCase;
+use Core\Mcp\Context\WorkspaceContext;
+
+class WorkspaceContextTest extends TestCase
+{
+ public function test_resolves_from_header(): void
+ {
+ $workspace = Workspace::factory()->create();
+
+ $response = $this->withHeaders([
+ 'X-Workspace-ID' => $workspace->id,
+ ])->postJson('/mcp/query', [...]);
+
+ $response->assertStatus(200);
+ }
+
+ public function test_scopes_queries_to_workspace(): void
+ {
+ $workspace1 = Workspace::factory()->create();
+ $workspace2 = Workspace::factory()->create();
+
+ Post::factory()->create(['workspace_id' => $workspace1->id]);
+ Post::factory()->create(['workspace_id' => $workspace2->id]);
+
+ $context = new WorkspaceContext($workspace1);
+
+ $result = $tool->execute([
+ 'query' => 'SELECT * FROM posts',
+ ], $context);
+
+ $this->assertCount(1, $result['rows']);
+ $this->assertEquals($workspace1->id, $result['rows'][0]['workspace_id']);
+ }
+
+ public function test_throws_when_context_missing(): void
+ {
+ $this->expectException(MissingWorkspaceContextException::class);
+
+ $tool->execute(['query' => 'SELECT * FROM posts']);
+ }
+}
+```
+
+## Best Practices
+
+### 1. Always Validate Context
+
+```php
+// ✅ Good - validate context
+public function execute(array $params)
+{
+ $this->validateWorkspaceContext();
+ // ...
+}
+
+// ❌ Bad - no validation
+public function execute(array $params)
+{
+ // Potential workspace bypass
+}
+```
+
+### 2. Use BelongsToWorkspace Trait
+
+```php
+// ✅ Good - automatic scoping
+class Post extends Model
+{
+ use BelongsToWorkspace;
+}
+
+// ❌ Bad - manual filtering
+Post::where('workspace_id', $workspace->id)->get();
+```
+
+### 3. Provide Clear Errors
+
+```php
+// ✅ Good - helpful error
+throw new MissingWorkspaceContextException(
+ 'Please provide X-Workspace-ID header'
+);
+
+// ❌ Bad - generic error
+throw new Exception('Error');
+```
+
+### 4. Test Context Isolation
+
+```php
+// ✅ Good - test workspace boundaries
+public function test_cannot_access_other_workspace(): void
+{
+ $workspace1 = Workspace::factory()->create();
+ $workspace2 = Workspace::factory()->create();
+
+ $context = new WorkspaceContext($workspace1);
+
+ $post = Post::factory()->create(['workspace_id' => $workspace2->id]);
+
+ $result = Post::find($post->id); // Should be null
+
+ $this->assertNull($result);
+}
+```
+
+## Learn More
+
+- [Multi-Tenancy →](/packages/core/tenancy)
+- [Security →](/packages/mcp/security)
+- [Creating Tools →](/packages/mcp/tools)
diff --git a/docs/packages/tenant/architecture.md b/docs/packages/tenant/architecture.md
new file mode 100644
index 0000000..22b4b99
--- /dev/null
+++ b/docs/packages/tenant/architecture.md
@@ -0,0 +1,422 @@
+---
+title: Architecture
+description: Technical architecture of the core-tenant multi-tenancy package
+updated: 2026-01-29
+---
+
+# core-tenant Architecture
+
+This document describes the technical architecture of the core-tenant package, which provides multi-tenancy, user management, and entitlement systems for the Host UK platform.
+
+## Overview
+
+core-tenant is the foundational tenancy layer that enables:
+
+- **Workspaces** - The primary tenant boundary (organisations, teams)
+- **Namespaces** - Product-level isolation within or across workspaces
+- **Entitlements** - Feature access control, usage limits, and billing integration
+- **User Management** - Authentication, 2FA, and workspace membership
+
+## Core Concepts
+
+### Tenant Hierarchy
+
+```
+User
+├── owns Workspaces (can own multiple)
+│ ├── has WorkspacePackages (entitlements)
+│ ├── has Boosts (temporary limit increases)
+│ ├── has Members (users with roles/permissions)
+│ ├── has Teams (permission groups)
+│ └── owns Namespaces (product boundaries)
+└── owns Namespaces (personal, not workspace-linked)
+```
+
+### Workspace
+
+The `Workspace` model is the primary tenant boundary. All tenant-scoped data references a workspace_id.
+
+**Key Properties:**
+- `slug` - URL-safe unique identifier
+- `domain` - Optional custom domain
+- `settings` - JSON configuration blob
+- `stripe_customer_id` / `btcpay_customer_id` - Billing integration
+
+**Relationships:**
+- `users()` - Members via pivot table
+- `workspacePackages()` - Active entitlement packages
+- `boosts()` - Temporary limit increases
+- `namespaces()` - Owned namespaces (polymorphic)
+
+### Namespace
+
+The `Namespace_` model provides a universal product boundary. Products belong to namespaces rather than directly to users/workspaces.
+
+**Ownership Patterns:**
+1. **User-owned**: Individual creator with personal namespace
+2. **Workspace-owned**: Agency managing client namespaces
+3. **User with workspace billing**: Personal namespace but billed to workspace
+
+**Entitlement Cascade:**
+1. Check namespace-level packages first
+2. Fall back to workspace pool (if namespace has workspace_id)
+3. Fall back to user tier (for user-owned namespaces)
+
+### BelongsToWorkspace Trait
+
+Models that are workspace-scoped should use the `BelongsToWorkspace` trait:
+
+```php
+class Account extends Model
+{
+ use BelongsToWorkspace;
+}
+```
+
+**Security Features:**
+- Auto-assigns `workspace_id` on create (or throws exception)
+- Provides `ownedByCurrentWorkspace()` scope
+- Auto-invalidates workspace cache on model changes
+
+**Strict Mode:**
+When `WorkspaceScope::isStrictModeEnabled()` is true:
+- Creating models without workspace context throws `MissingWorkspaceContextException`
+- Querying without context throws exception
+- This prevents accidental cross-tenant data access
+
+## Entitlement System
+
+### Feature Types
+
+Features (`entitlement_features` table) have three types:
+
+| Type | Description | Example |
+|------|-------------|---------|
+| `boolean` | On/off access | Beta features |
+| `limit` | Numeric limit with usage tracking | 100 AI credits/month |
+| `unlimited` | No limit | Unlimited social accounts |
+
+### Reset Types
+
+| Type | Description |
+|------|-------------|
+| `none` | No reset (cumulative) |
+| `monthly` | Resets at billing cycle start |
+| `rolling` | Rolling window (e.g., last 30 days) |
+
+### Package Model
+
+Packages bundle features with specific limits:
+
+```
+Package (creator)
+├── Feature: ai.credits (limit: 100)
+├── Feature: social.accounts (limit: 5)
+└── Feature: tier.apollo (boolean)
+```
+
+### Boost Model
+
+Boosts provide temporary limit increases:
+
+| Boost Type | Description |
+|------------|-------------|
+| `add_limit` | Adds to existing limit |
+| `enable` | Enables a boolean feature |
+| `unlimited` | Makes feature unlimited |
+
+| Duration Type | Description |
+|---------------|-------------|
+| `cycle_bound` | Expires at billing cycle end |
+| `duration` | Expires after set period |
+| `permanent` | Never expires |
+
+### Entitlement Check Flow
+
+```
+EntitlementService::can($workspace, 'ai.credits', quantity: 5)
+│
+├─> Get Feature by code
+│ └─> Get pool feature code (for hierarchical features)
+│
+├─> Calculate total limit
+│ ├─> Sum limits from active WorkspacePackages
+│ └─> Add remaining limits from active Boosts
+│
+├─> Get current usage
+│ ├─> Check reset type (monthly/rolling/none)
+│ └─> Sum UsageRecords in window
+│
+└─> Return EntitlementResult
+ ├─> allowed: bool
+ ├─> limit: int|null
+ ├─> used: int
+ ├─> remaining: int|null
+ └─> reason: string (if denied)
+```
+
+### Caching Strategy
+
+Entitlement data is cached with 5-minute TTL:
+- `entitlement:{workspace_id}:limit:{feature_code}`
+- `entitlement:{workspace_id}:usage:{feature_code}`
+
+Cache invalidation occurs on:
+- Package provision/suspend/cancel
+- Boost provision/expire
+- Usage recording
+
+## Service Layer
+
+### WorkspaceManager
+
+Manages workspace context and basic CRUD:
+
+```php
+$manager = app(WorkspaceManager::class);
+$manager->setCurrent($workspace); // Set context
+$manager->loadBySlug('acme'); // Load by slug
+$manager->create($user, $attrs); // Create workspace
+$manager->addUser($workspace, $user); // Add member
+```
+
+### EntitlementService
+
+Core API for entitlement checks and management:
+
+```php
+$service = app(EntitlementService::class);
+
+// Check feature access
+$result = $service->can($workspace, 'ai.credits', quantity: 5);
+if ($result->isAllowed()) {
+ // Record usage after action
+ $service->recordUsage($workspace, 'ai.credits', quantity: 5);
+}
+
+// Provision packages
+$service->provisionPackage($workspace, 'creator', [
+ 'source' => 'blesta',
+ 'billing_cycle_anchor' => now(),
+]);
+
+// Suspend/reactivate
+$service->suspendWorkspace($workspace);
+$service->reactivateWorkspace($workspace);
+```
+
+### WorkspaceTeamService
+
+Manages teams and permissions:
+
+```php
+$teamService = app(WorkspaceTeamService::class);
+$teamService->forWorkspace($workspace);
+
+// Check permissions
+if ($teamService->hasPermission($user, 'social.write')) {
+ // User can write social content
+}
+
+// Team management
+$team = $teamService->createTeam([
+ 'name' => 'Content Creators',
+ 'permissions' => ['social.read', 'social.write'],
+]);
+$teamService->addMemberToTeam($user, $team);
+```
+
+### WorkspaceCacheManager
+
+Workspace-scoped caching with tag support:
+
+```php
+$cache = app(WorkspaceCacheManager::class);
+
+// Cache workspace data
+$data = $cache->remember($workspace, 'expensive-query', 300, function () {
+ return ExpensiveModel::forWorkspace($workspace)->get();
+});
+
+// Flush workspace cache
+$cache->flush($workspace);
+```
+
+## Middleware
+
+### RequireWorkspaceContext
+
+Ensures workspace context before processing:
+
+```php
+Route::middleware('workspace.required')->group(function () {
+ // Routes here require workspace context
+});
+
+// With user access validation
+Route::middleware('workspace.required:validate')->group(function () {
+ // Also validates user has access to workspace
+});
+```
+
+Workspace resolved from (in order):
+1. Request attribute `workspace_model`
+2. `Workspace::current()` (session/auth)
+3. Request input `workspace_id`
+4. Header `X-Workspace-ID`
+5. Query param `workspace`
+
+### CheckWorkspacePermission
+
+Checks user has specific permissions:
+
+```php
+Route::middleware('workspace.permission:social.write')->group(function () {
+ // Requires social.write permission
+});
+
+// Multiple permissions (OR logic)
+Route::middleware('workspace.permission:admin,owner')->group(function () {
+ // Requires admin OR owner role
+});
+```
+
+## Event System
+
+### Lifecycle Events
+
+The module uses event-driven lazy loading:
+
+```php
+class Boot extends ServiceProvider
+{
+ public static array $listens = [
+ AdminPanelBooting::class => 'onAdminPanel',
+ ApiRoutesRegistering::class => 'onApiRoutes',
+ WebRoutesRegistering::class => 'onWebRoutes',
+ ConsoleBooting::class => 'onConsole',
+ ];
+}
+```
+
+### Entitlement Webhooks
+
+External systems can subscribe to entitlement events:
+
+| Event | Trigger |
+|-------|---------|
+| `limit_warning` | Usage at 80% or 90% |
+| `limit_reached` | Usage at 100% |
+| `package_changed` | Package add/change/remove |
+| `boost_activated` | Boost provisioned |
+| `boost_expired` | Boost expired |
+
+Webhooks include:
+- HMAC-SHA256 signature verification
+- Automatic retry with exponential backoff
+- Circuit breaker after consecutive failures
+
+## Two-Factor Authentication
+
+### TotpService
+
+RFC 6238 compliant TOTP implementation:
+
+```php
+$totp = app(TwoFactorAuthenticationProvider::class);
+
+// Generate secret
+$secret = $totp->generateSecretKey(); // 160-bit base32
+
+// Generate QR code URL
+$url = $totp->qrCodeUrl('AppName', $user->email, $secret);
+
+// Verify code
+if ($totp->verify($secret, $userCode)) {
+ // Valid
+}
+```
+
+### TwoFactorAuthenticatable Trait
+
+Add to User model:
+
+```php
+class User extends Authenticatable
+{
+ use TwoFactorAuthenticatable;
+}
+
+// Enable 2FA
+$secret = $user->enableTwoFactorAuth();
+// User scans QR, enters code
+if ($user->verifyTwoFactorCode($code)) {
+ $recoveryCodes = $user->confirmTwoFactorAuth();
+}
+
+// Disable
+$user->disableTwoFactorAuth();
+```
+
+## Database Schema
+
+### Core Tables
+
+| Table | Purpose |
+|-------|---------|
+| `users` | User accounts |
+| `workspaces` | Tenant organisations |
+| `user_workspace` | User-workspace pivot |
+| `namespaces` | Product boundaries |
+
+### Entitlement Tables
+
+| Table | Purpose |
+|-------|---------|
+| `entitlement_features` | Feature definitions |
+| `entitlement_packages` | Package definitions |
+| `entitlement_package_features` | Package-feature pivot |
+| `entitlement_workspace_packages` | Workspace package assignments |
+| `entitlement_namespace_packages` | Namespace package assignments |
+| `entitlement_boosts` | Active boosts |
+| `entitlement_usage_records` | Usage tracking |
+| `entitlement_logs` | Audit log |
+
+### Team Tables
+
+| Table | Purpose |
+|-------|---------|
+| `workspace_teams` | Team definitions |
+| `workspace_invitations` | Pending invitations |
+
+## Configuration
+
+The package uses these config keys:
+
+```php
+// config/core.php
+return [
+ 'workspace_cache' => [
+ 'enabled' => true,
+ 'ttl' => 300,
+ 'prefix' => 'workspace_cache',
+ 'use_tags' => true,
+ ],
+];
+```
+
+## Testing
+
+Tests are in `tests/Feature/` using Pest:
+
+```bash
+composer test # All tests
+vendor/bin/pest tests/Feature/EntitlementServiceTest.php # Single file
+vendor/bin/pest --filter="can method" # Filter by name
+```
+
+Key test files:
+- `EntitlementServiceTest.php` - Core entitlement logic
+- `WorkspaceSecurityTest.php` - Tenant isolation
+- `WorkspaceCacheTest.php` - Caching behaviour
+- `TwoFactorAuthenticatableTest.php` - 2FA flows
diff --git a/docs/packages/tenant/entitlements.md b/docs/packages/tenant/entitlements.md
new file mode 100644
index 0000000..ec72523
--- /dev/null
+++ b/docs/packages/tenant/entitlements.md
@@ -0,0 +1,465 @@
+---
+title: Entitlements
+description: Guide to the entitlement system for feature access and usage limits
+updated: 2026-01-29
+---
+
+# Entitlement System
+
+The entitlement system controls feature access, usage limits, and billing integration for workspaces and namespaces.
+
+## Quick Start
+
+### Check Feature Access
+
+```php
+use Core\Tenant\Services\EntitlementService;
+
+$entitlements = app(EntitlementService::class);
+
+// Check if workspace can use a feature
+$result = $entitlements->can($workspace, 'ai.credits', quantity: 5);
+
+if ($result->isAllowed()) {
+ // Perform action
+ $entitlements->recordUsage($workspace, 'ai.credits', quantity: 5, user: $user);
+} else {
+ // Handle denial
+ return response()->json([
+ 'error' => $result->reason,
+ 'limit' => $result->limit,
+ 'used' => $result->used,
+ ], 403);
+}
+```
+
+### Via Workspace Model
+
+```php
+$result = $workspace->can('social.accounts');
+
+if ($result->isAllowed()) {
+ $workspace->recordUsage('social.accounts');
+}
+```
+
+## Concepts
+
+### Features
+
+Features are defined in the `entitlement_features` table:
+
+| Field | Description |
+|-------|-------------|
+| `code` | Unique identifier (e.g., `ai.credits`, `social.accounts`) |
+| `type` | `boolean`, `limit`, or `unlimited` |
+| `reset_type` | `none`, `monthly`, or `rolling` |
+| `rolling_window_days` | Days for rolling window |
+| `parent_feature_id` | For hierarchical features (pool sharing) |
+
+**Feature Types:**
+
+| Type | Behaviour |
+|------|-----------|
+| `boolean` | Binary on/off access |
+| `limit` | Numeric limit with usage tracking |
+| `unlimited` | Feature enabled with no limits |
+
+**Reset Types:**
+
+| Type | Behaviour |
+|------|-----------|
+| `none` | Usage accumulates forever |
+| `monthly` | Resets at billing cycle start |
+| `rolling` | Rolling window (e.g., last 30 days) |
+
+### Packages
+
+Packages bundle features with specific limits:
+
+```php
+// Example package definition
+$package = Package::create([
+ 'code' => 'creator',
+ 'name' => 'Creator Plan',
+ 'is_base_package' => true,
+ 'monthly_price' => 19.99,
+]);
+
+// Attach features
+$package->features()->attach($aiCreditsFeature->id, ['limit_value' => 100]);
+$package->features()->attach($socialAccountsFeature->id, ['limit_value' => 5]);
+```
+
+### Workspace Packages
+
+Packages are provisioned to workspaces:
+
+```php
+$workspacePackage = $entitlements->provisionPackage($workspace, 'creator', [
+ 'source' => EntitlementLog::SOURCE_BLESTA,
+ 'billing_cycle_anchor' => now(),
+ 'blesta_service_id' => 'srv_12345',
+]);
+```
+
+**Statuses:**
+- `active` - Package is in use
+- `suspended` - Temporarily disabled (e.g., payment failed)
+- `cancelled` - Permanently ended
+- `expired` - Past expiry date
+
+### Boosts
+
+Boosts provide temporary limit increases:
+
+```php
+$boost = $entitlements->provisionBoost($workspace, 'ai.credits', [
+ 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
+ 'limit_value' => 50,
+ 'duration_type' => Boost::DURATION_CYCLE_BOUND,
+]);
+```
+
+**Boost Types:**
+
+| Type | Effect |
+|------|--------|
+| `add_limit` | Adds to package limit |
+| `enable` | Enables boolean feature |
+| `unlimited` | Makes feature unlimited |
+
+**Duration Types:**
+
+| Type | Expiry |
+|------|--------|
+| `cycle_bound` | Expires at billing cycle end |
+| `duration` | Expires after set `expires_at` |
+| `permanent` | Never expires |
+
+## API Reference
+
+### EntitlementService
+
+#### can()
+
+Check if a workspace can use a feature:
+
+```php
+public function can(
+ Workspace $workspace,
+ string $featureCode,
+ int $quantity = 1
+): EntitlementResult
+```
+
+**Returns `EntitlementResult` with:**
+- `isAllowed(): bool`
+- `isDenied(): bool`
+- `isUnlimited(): bool`
+- `limit: ?int`
+- `used: int`
+- `remaining: ?int`
+- `reason: ?string`
+- `featureCode: string`
+- `getUsagePercentage(): ?float`
+- `isNearLimit(): bool` (>80%)
+- `isAtLimit(): bool` (100%)
+
+#### canForNamespace()
+
+Check entitlement for a namespace with cascade:
+
+```php
+public function canForNamespace(
+ Namespace_ $namespace,
+ string $featureCode,
+ int $quantity = 1
+): EntitlementResult
+```
+
+Cascade order:
+1. Namespace-level packages
+2. Workspace pool (if `namespace->workspace_id` set)
+3. User tier (if namespace owned by user)
+
+#### recordUsage()
+
+Record feature usage:
+
+```php
+public function recordUsage(
+ Workspace $workspace,
+ string $featureCode,
+ int $quantity = 1,
+ ?User $user = null,
+ ?array $metadata = null
+): UsageRecord
+```
+
+#### provisionPackage()
+
+Assign a package to a workspace:
+
+```php
+public function provisionPackage(
+ Workspace $workspace,
+ string $packageCode,
+ array $options = []
+): WorkspacePackage
+```
+
+**Options:**
+- `source` - `system`, `blesta`, `admin`, `user`
+- `billing_cycle_anchor` - Start of billing cycle
+- `expires_at` - Package expiry date
+- `blesta_service_id` - External billing reference
+- `metadata` - Additional data
+
+#### provisionBoost()
+
+Add a temporary boost:
+
+```php
+public function provisionBoost(
+ Workspace $workspace,
+ string $featureCode,
+ array $options = []
+): Boost
+```
+
+**Options:**
+- `boost_type` - `add_limit`, `enable`, `unlimited`
+- `duration_type` - `cycle_bound`, `duration`, `permanent`
+- `limit_value` - Amount to add (for `add_limit`)
+- `expires_at` - Expiry date (for `duration`)
+
+#### suspendWorkspace() / reactivateWorkspace()
+
+Manage workspace package status:
+
+```php
+$entitlements->suspendWorkspace($workspace, 'blesta');
+$entitlements->reactivateWorkspace($workspace, 'admin');
+```
+
+#### getUsageSummary()
+
+Get all feature usage for a workspace:
+
+```php
+$summary = $entitlements->getUsageSummary($workspace);
+
+// Returns Collection grouped by category:
+// [
+// 'ai' => [
+// ['code' => 'ai.credits', 'limit' => 100, 'used' => 50, ...],
+// ],
+// 'social' => [
+// ['code' => 'social.accounts', 'limit' => 5, 'used' => 3, ...],
+// ],
+// ]
+```
+
+## Namespace-Level Entitlements
+
+For products that operate at namespace level:
+
+```php
+$result = $entitlements->canForNamespace($namespace, 'bio.pages');
+
+if ($result->isAllowed()) {
+ $entitlements->recordNamespaceUsage($namespace, 'bio.pages', user: $user);
+}
+
+// Provision namespace-specific package
+$entitlements->provisionNamespacePackage($namespace, 'bio-pro');
+```
+
+## Usage Alerts
+
+The `UsageAlertService` monitors usage and sends notifications:
+
+```php
+// Check single workspace
+$alerts = app(UsageAlertService::class)->checkWorkspace($workspace);
+
+// Check all workspaces (scheduled command)
+php artisan tenant:check-usage-alerts
+```
+
+**Alert Thresholds:**
+- 80% - Warning
+- 90% - Critical
+- 100% - Limit reached
+
+**Notification Channels:**
+- Email to workspace owner
+- Webhook events (`limit_warning`, `limit_reached`)
+
+## Billing Integration
+
+### Blesta API
+
+External endpoints for billing system integration:
+
+```
+POST /api/entitlements - Provision package
+POST /api/entitlements/{id}/suspend - Suspend
+POST /api/entitlements/{id}/unsuspend - Reactivate
+POST /api/entitlements/{id}/cancel - Cancel
+POST /api/entitlements/{id}/renew - Renew
+GET /api/entitlements/{id} - Get details
+```
+
+### Cross-App API
+
+For other Host UK services to check entitlements:
+
+```
+GET /api/entitlements/check - Check feature access
+POST /api/entitlements/usage - Record usage
+GET /api/entitlements/summary - Get usage summary
+```
+
+## Webhooks
+
+Subscribe to entitlement events:
+
+```php
+$webhookService = app(EntitlementWebhookService::class);
+
+$webhook = $webhookService->register($workspace,
+ name: 'Usage Alerts',
+ url: 'https://api.example.com/hooks/entitlements',
+ events: ['limit_warning', 'limit_reached']
+);
+```
+
+**Available Events:**
+- `limit_warning` - 80%/90% threshold
+- `limit_reached` - 100% threshold
+- `package_changed` - Package add/change/remove
+- `boost_activated` - New boost
+- `boost_expired` - Boost expired
+
+**Payload Format:**
+
+```json
+{
+ "event": "limit_warning",
+ "data": {
+ "workspace_id": 123,
+ "feature_code": "ai.credits",
+ "threshold": 80,
+ "used": 80,
+ "limit": 100
+ },
+ "timestamp": "2026-01-29T12:00:00Z"
+}
+```
+
+**Verification:**
+
+```php
+$isValid = $webhookService->verifySignature(
+ $payload,
+ $request->header('X-Signature'),
+ $webhook->secret
+);
+```
+
+## Best Practices
+
+### Check Before Action
+
+Always check entitlements before performing the action:
+
+```php
+// Bad: Check after action
+$account = SocialAccount::create([...]);
+if (!$workspace->can('social.accounts')->isAllowed()) {
+ $account->delete();
+ throw new \Exception('Limit exceeded');
+}
+
+// Good: Check before action
+$result = $workspace->can('social.accounts');
+if ($result->isDenied()) {
+ throw new EntitlementException($result->reason);
+}
+$account = SocialAccount::create([...]);
+$workspace->recordUsage('social.accounts');
+```
+
+### Use Transactions
+
+For atomic check-and-record:
+
+```php
+DB::transaction(function () use ($workspace, $user) {
+ $result = $workspace->can('ai.credits', 10);
+
+ if ($result->isDenied()) {
+ throw new EntitlementException($result->reason);
+ }
+
+ // Perform AI generation
+ $output = $aiService->generate($prompt);
+
+ // Record usage
+ $workspace->recordUsage('ai.credits', 10, $user, [
+ 'model' => 'claude-3',
+ 'tokens' => 1500,
+ ]);
+
+ return $output;
+});
+```
+
+### Cache Considerations
+
+Entitlement checks are cached for 5 minutes. For real-time accuracy:
+
+```php
+// Force cache refresh
+$entitlements->invalidateCache($workspace);
+$result = $entitlements->can($workspace, 'feature');
+```
+
+### Feature Code Conventions
+
+Use dot notation for feature codes:
+
+```
+service.feature
+service.feature.subfeature
+```
+
+Examples:
+- `ai.credits`
+- `social.accounts`
+- `social.posts.scheduled`
+- `bio.pages`
+- `analytics.websites`
+
+### Hierarchical Features
+
+For shared pools, use parent features:
+
+```php
+// Parent feature (pool)
+$aiCredits = Feature::create([
+ 'code' => 'ai.credits',
+ 'type' => Feature::TYPE_LIMIT,
+]);
+
+// Child feature (uses parent pool)
+$aiGeneration = Feature::create([
+ 'code' => 'ai.generation',
+ 'parent_feature_id' => $aiCredits->id,
+]);
+
+// Both check against ai.credits pool
+$workspace->can('ai.generation'); // Uses ai.credits limit
+```
diff --git a/docs/packages/tenant/index.md b/docs/packages/tenant/index.md
new file mode 100644
index 0000000..7fc2c34
--- /dev/null
+++ b/docs/packages/tenant/index.md
@@ -0,0 +1,48 @@
+---
+title: Tenant Package
+navTitle: Tenant
+navOrder: 10
+---
+
+# Tenant Package
+
+Multi-tenancy, user management, and entitlement systems for the Host UK platform.
+
+## Overview
+
+The `core-tenant` package is the foundational tenancy layer that provides workspace isolation, user authentication, and feature access control.
+
+## Core Concepts
+
+### Tenant Hierarchy
+
+```
+User
+├── owns Workspaces (can own multiple)
+│ ├── has WorkspacePackages (entitlements)
+│ ├── has Boosts (temporary limit increases)
+│ ├── has Members (users with roles/permissions)
+│ ├── has Teams (permission groups)
+│ └── owns Namespaces (product boundaries)
+└── owns Namespaces (personal, not workspace-linked)
+```
+
+## Features
+
+- **Workspaces** - Primary tenant boundary (organisations, teams)
+- **Namespaces** - Product-level isolation within or across workspaces
+- **Entitlements** - Feature access control and usage limits
+- **User management** - Authentication, 2FA, and membership
+- **Teams** - Permission groups within workspaces
+
+## Installation
+
+```bash
+composer require host-uk/core-tenant
+```
+
+## Documentation
+
+- [Architecture](./architecture) - Technical architecture
+- [Entitlements](./entitlements) - Feature access and limits
+- [Security](./security) - Tenant isolation and security
\ No newline at end of file
diff --git a/docs/packages/tenant/security.md b/docs/packages/tenant/security.md
new file mode 100644
index 0000000..15f9e9f
--- /dev/null
+++ b/docs/packages/tenant/security.md
@@ -0,0 +1,309 @@
+---
+title: Security
+description: Security considerations and audit notes for core-tenant
+updated: 2026-01-29
+---
+
+# Security Considerations
+
+This document outlines security considerations, implemented protections, and known areas requiring attention in the core-tenant package.
+
+## Multi-Tenant Data Isolation
+
+### Workspace Scope Enforcement
+
+The primary security mechanism is the `BelongsToWorkspace` trait which enforces workspace isolation at the model level.
+
+**How it works:**
+
+1. **Strict Mode** (default in web requests): Queries without workspace context throw `MissingWorkspaceContextException`
+2. **Auto-assignment**: Creating models without explicit `workspace_id` uses current context or throws
+3. **Cache invalidation**: Model changes automatically invalidate workspace-scoped cache
+
+**Code paths:**
+
+```php
+// SAFE: Explicit workspace context
+Account::forWorkspace($workspace)->get();
+
+// SAFE: Uses current workspace from request
+Account::ownedByCurrentWorkspace()->get();
+
+// THROWS in strict mode: No workspace context
+Account::query()->get(); // MissingWorkspaceContextException
+
+// DANGEROUS: Bypasses scope - use with caution
+Account::query()->acrossWorkspaces()->get();
+WorkspaceScope::withoutStrictMode(fn() => Account::all());
+```
+
+### Middleware Protection
+
+| Middleware | Purpose |
+|------------|---------|
+| `RequireWorkspaceContext` | Ensures workspace is set before route handling |
+| `CheckWorkspacePermission` | Validates user has required permissions |
+
+**Recommendation:** Always use `workspace.required:validate` for user-facing routes to ensure the authenticated user actually has access to the resolved workspace.
+
+### Known Gaps
+
+1. **SEC-006**: The `RequireWorkspaceContext` middleware accepts workspace from headers/query params without mandatory user access validation. The `validate` parameter should be the default.
+
+2. **Cross-tenant API**: The `EntitlementApiController` accepts workspace lookups by email, which could allow enumeration of user-workspace associations. Consider adding authentication scopes.
+
+## Authentication Security
+
+### Password Storage
+
+Passwords are hashed using bcrypt via Laravel's `hashed` cast:
+
+```php
+protected function casts(): array
+{
+ return [
+ 'password' => 'hashed',
+ ];
+}
+```
+
+### Two-Factor Authentication
+
+**Implemented:**
+- TOTP (RFC 6238) with 30-second time steps
+- 6-digit codes with SHA-1 HMAC
+- Clock drift tolerance (1 window each direction)
+- 8 recovery codes (20 characters each)
+
+**Security Considerations:**
+
+1. **SEC-003**: TOTP secrets are stored in plaintext. Should use Laravel's `encrypted` cast.
+ - File: `Models/UserTwoFactorAuth.php`
+ - Risk: Database breach exposes all 2FA secrets
+ - Mitigation: Use `'secret_key' => 'encrypted'` cast
+
+2. Recovery codes are stored as JSON array. Consider hashing each code individually.
+
+3. No brute-force protection on TOTP verification endpoint (rate limiting should be applied at route level).
+
+### Session Security
+
+Standard Laravel session handling with:
+- `sessions` table for database driver
+- IP address and user agent tracking
+- `remember_token` for persistent sessions
+
+## API Security
+
+### Blesta Integration API
+
+The `EntitlementApiController` provides endpoints for external billing system integration:
+
+| Endpoint | Risk | Mitigation |
+|----------|------|------------|
+| `POST /store` | Creates users/workspaces | Requires API auth |
+| `POST /suspend/{id}` | Suspends access | Requires API auth |
+| `POST /cancel/{id}` | Cancels packages | Requires API auth |
+
+**Known Issues:**
+
+1. **SEC-001**: No rate limiting on API endpoints
+ - Risk: Compromised API key could mass-provision accounts
+ - Mitigation: Add rate limiting middleware
+
+2. **SEC-002**: API authentication not visible in `Routes/api.php`
+ - Action: Verify Blesta routes have proper auth middleware
+
+### Webhook Security
+
+**Implemented:**
+- HMAC-SHA256 signature on all payloads
+- `X-Signature` header for verification
+- 32-byte random secrets (256-bit)
+
+**Code:**
+```php
+// Signing (outbound)
+$signature = hash_hmac('sha256', json_encode($payload), $webhook->secret);
+
+// Verification (inbound)
+$expected = hash_hmac('sha256', $payload, $secret);
+return hash_equals($expected, $signature);
+```
+
+**Known Issues:**
+
+1. **SEC-005**: Webhook test endpoint could be SSRF vector
+ - Risk: Attacker could probe internal network via webhook URL
+ - Mitigation: Validate URLs against blocklist, prevent internal IPs
+
+### Invitation Tokens
+
+**Implemented:**
+- 64-character random tokens (`Str::random(64)`)
+- Expiration dates with default 7-day TTL
+- Single-use (marked accepted_at after use)
+
+**Known Issues:**
+
+1. **SEC-004**: Tokens stored in plaintext
+ - Risk: Database breach exposes all pending invitations
+ - Mitigation: Store hash, compare with `hash_equals()`
+
+2. No rate limiting on invitation acceptance endpoint
+ - Risk: Brute-force token guessing (though 64 chars is large keyspace)
+ - Mitigation: Add rate limiting, log failed attempts
+
+## Input Validation
+
+### EntitlementApiController
+
+```php
+$validated = $request->validate([
+ 'email' => 'required|email',
+ 'name' => 'required|string|max:255',
+ 'product_code' => 'required|string',
+ 'billing_cycle_anchor' => 'nullable|date',
+ 'expires_at' => 'nullable|date',
+ 'blesta_service_id' => 'nullable|string',
+]);
+```
+
+**Note:** `blesta_service_id` and `product_code` are not sanitised for special characters. Consider adding regex validation if these are displayed in UI.
+
+### Workspace Manager Validation Rules
+
+The `WorkspaceManager` provides scoped uniqueness rules:
+
+```php
+// Ensures uniqueness within workspace
+$manager->uniqueRule('social_accounts', 'handle', softDelete: true);
+```
+
+## Logging and Audit
+
+### Entitlement Logs
+
+All entitlement changes are logged to `entitlement_logs`:
+
+```php
+EntitlementLog::logPackageAction(
+ $workspace,
+ EntitlementLog::ACTION_PACKAGE_PROVISIONED,
+ $workspacePackage,
+ source: EntitlementLog::SOURCE_BLESTA,
+ newValues: $workspacePackage->toArray()
+);
+```
+
+**Logged actions:**
+- Package provision/suspend/cancel/reactivate/renew
+- Boost provision/expire/cancel
+- Usage recording
+
+**Not logged (should consider):**
+- Workspace creation/deletion
+- Member additions/removals
+- Permission changes
+- Login attempts
+
+### Security Event Logging
+
+Currently limited. Recommend adding:
+- Failed authentication attempts
+- 2FA setup/disable events
+- Invitation accept/reject
+- API key usage
+
+## Sensitive Data Handling
+
+### Hidden Attributes
+
+```php
+// User model
+protected $hidden = [
+ 'password',
+ 'remember_token',
+];
+
+// Workspace model
+protected $hidden = [
+ 'wp_connector_secret',
+];
+```
+
+### Guarded Attributes
+
+```php
+// Workspace model
+protected $guarded = [
+ 'wp_connector_secret',
+];
+```
+
+**Note:** Using `$fillable` is generally safer than `$guarded` for sensitive models.
+
+## Recommendations
+
+### Immediate (P1)
+
+1. Add rate limiting to all API endpoints
+2. Encrypt 2FA secrets at rest
+3. Hash invitation tokens before storage
+4. Validate webhook URLs against SSRF attacks
+5. Make user access validation default in RequireWorkspaceContext
+
+### Short-term (P2)
+
+1. Add comprehensive security event logging
+2. Implement brute-force protection for:
+ - 2FA verification
+ - Invitation acceptance
+ - Password reset
+3. Add API scopes for entitlement operations
+4. Implement session fingerprinting (detect session hijacking)
+
+### Long-term (P3)
+
+1. Consider WebAuthn/FIDO2 as 2FA alternative
+2. Implement cryptographic binding between user sessions and workspace access
+3. Add anomaly detection for unusual entitlement patterns
+4. Consider field-level encryption for sensitive workspace data
+
+## Security Testing
+
+### Existing Tests
+
+- `WorkspaceSecurityTest.php` - Tests tenant isolation
+- `TwoFactorAuthenticatableTest.php` - Tests 2FA flows
+
+### Recommended Additional Tests
+
+1. Test workspace scope bypass attempts
+2. Test API authentication failure handling
+3. Test rate limiting behaviour
+4. Test SSRF protection on webhook URLs
+5. Test invitation token brute-force protection
+
+## Compliance Notes
+
+### GDPR Considerations
+
+1. **Account Deletion**: `ProcessAccountDeletion` job handles user data removal
+2. **Data Export**: Not currently implemented (consider adding)
+3. **Consent Tracking**: Not in scope of this package
+
+### PCI DSS
+
+If handling payment data:
+- `stripe_customer_id` and `btcpay_customer_id` are stored (tokens, not card data)
+- No direct card handling in this package
+- Billing details (name, address) stored in workspace model
+
+## Incident Response
+
+If you discover a security vulnerability:
+
+1. Do not disclose publicly
+2. Contact: security@host.uk.com (hypothetical)
+3. Include: Vulnerability description, reproduction steps, impact assessment
diff --git a/docs/packages/tenant/teams-permissions.md b/docs/packages/tenant/teams-permissions.md
new file mode 100644
index 0000000..572f1b8
--- /dev/null
+++ b/docs/packages/tenant/teams-permissions.md
@@ -0,0 +1,447 @@
+---
+title: Teams and Permissions
+description: Guide to workspace teams and permission management
+updated: 2026-01-29
+---
+
+# Teams and Permissions
+
+The team system provides fine-grained access control within workspaces through role-based teams with configurable permissions.
+
+## Overview
+
+```
+Workspace
+├── Teams (permission groups)
+│ ├── Owners (system team)
+│ ├── Admins (system team)
+│ ├── Members (system team, default)
+│ └── Custom teams...
+└── Members (users in workspace)
+ └── assigned to Team (or custom_permissions)
+```
+
+## Quick Start
+
+### Check Permissions
+
+```php
+use Core\Tenant\Services\WorkspaceTeamService;
+
+$teamService = app(WorkspaceTeamService::class);
+$teamService->forWorkspace($workspace);
+
+// Single permission
+if ($teamService->hasPermission($user, 'social.write')) {
+ // User can create/edit social content
+}
+
+// Any of multiple permissions
+if ($teamService->hasAnyPermission($user, ['admin', 'owner'])) {
+ // User is admin or owner
+}
+
+// All permissions required
+if ($teamService->hasAllPermissions($user, ['social.read', 'social.write'])) {
+ // User has both permissions
+}
+```
+
+### Via Middleware
+
+```php
+// Single permission
+Route::middleware('workspace.permission:social.write')
+ ->group(function () {
+ Route::post('/posts', [PostController::class, 'store']);
+ });
+
+// Multiple permissions (OR logic)
+Route::middleware('workspace.permission:admin,owner')
+ ->group(function () {
+ Route::get('/settings', [SettingsController::class, 'index']);
+ });
+```
+
+## System Teams
+
+Three system teams are created by default:
+
+### Owners
+
+```php
+[
+ 'slug' => 'owner',
+ 'permissions' => ['*'], // All permissions
+ 'is_system' => true,
+]
+```
+
+Workspace owners have unrestricted access to all features and settings.
+
+### Admins
+
+```php
+[
+ 'slug' => 'admin',
+ 'permissions' => [
+ 'workspace.read',
+ 'workspace.manage_settings',
+ 'workspace.manage_members',
+ 'workspace.manage_billing',
+ // ... all service permissions
+ ],
+ 'is_system' => true,
+]
+```
+
+Admins can manage workspace settings and members but cannot delete the workspace or transfer ownership.
+
+### Members
+
+```php
+[
+ 'slug' => 'member',
+ 'permissions' => [
+ 'workspace.read',
+ 'social.read', 'social.write',
+ 'bio.read', 'bio.write',
+ // ... basic service access
+ ],
+ 'is_system' => true,
+ 'is_default' => true,
+]
+```
+
+Default team for new members. Can use services but not manage workspace settings.
+
+## Permission Structure
+
+### Workspace Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `workspace.read` | View workspace details |
+| `workspace.manage_settings` | Edit workspace settings |
+| `workspace.manage_members` | Invite/remove members |
+| `workspace.manage_billing` | View/manage billing |
+
+### Service Permissions
+
+Each service follows the pattern: `service.read`, `service.write`, `service.delete`
+
+| Service | Permissions |
+|---------|-------------|
+| Social | `social.read`, `social.write`, `social.delete` |
+| Bio | `bio.read`, `bio.write`, `bio.delete` |
+| Analytics | `analytics.read`, `analytics.write` |
+| Notify | `notify.read`, `notify.write` |
+| Trust | `trust.read`, `trust.write` |
+| API | `api.read`, `api.write` |
+
+### Wildcard Permission
+
+The `*` permission grants access to everything. Only used by the Owners team.
+
+## WorkspaceTeamService API
+
+### Team Management
+
+```php
+$teamService = app(WorkspaceTeamService::class);
+$teamService->forWorkspace($workspace);
+
+// List teams
+$teams = $teamService->getTeams();
+
+// Get specific team
+$team = $teamService->getTeam($teamId);
+$team = $teamService->getTeamBySlug('content-creators');
+
+// Get default team for new members
+$defaultTeam = $teamService->getDefaultTeam();
+
+// Create custom team
+$team = $teamService->createTeam([
+ 'name' => 'Content Creators',
+ 'slug' => 'content-creators',
+ 'description' => 'Team for content creation staff',
+ 'permissions' => ['social.read', 'social.write', 'bio.read', 'bio.write'],
+ 'colour' => 'blue',
+]);
+
+// Update team
+$teamService->updateTeam($team, [
+ 'permissions' => [...$team->permissions, 'analytics.read'],
+]);
+
+// Delete team (non-system only)
+$teamService->deleteTeam($team);
+```
+
+### Member Management
+
+```php
+// Get member record
+$member = $teamService->getMember($user);
+
+// List all members
+$members = $teamService->getMembers();
+
+// List team members
+$teamMembers = $teamService->getTeamMembers($team);
+
+// Assign member to team
+$teamService->addMemberToTeam($user, $team);
+
+// Remove from team
+$teamService->removeMemberFromTeam($user);
+
+// Set custom permissions (override team)
+$teamService->setMemberCustomPermissions($user, [
+ 'social.read',
+ 'social.write',
+ // No social.delete
+]);
+```
+
+### Permission Checks
+
+```php
+// Get effective permissions
+$permissions = $teamService->getMemberPermissions($user);
+// Returns: ['workspace.read', 'social.read', 'social.write', ...]
+
+// Check single permission
+$teamService->hasPermission($user, 'social.write');
+
+// Check any permission (OR)
+$teamService->hasAnyPermission($user, ['admin', 'owner']);
+
+// Check all permissions (AND)
+$teamService->hasAllPermissions($user, ['social.read', 'social.write']);
+
+// Role checks
+$teamService->isOwner($user);
+$teamService->isAdmin($user);
+```
+
+## WorkspaceMember Model
+
+The `WorkspaceMember` model represents the user-workspace relationship:
+
+```php
+$member = WorkspaceMember::where('workspace_id', $workspace->id)
+ ->where('user_id', $user->id)
+ ->first();
+
+// Properties
+$member->role; // 'owner', 'admin', 'member'
+$member->team_id; // Associated team
+$member->custom_permissions; // Override permissions (JSON)
+$member->joined_at;
+$member->invited_by;
+
+// Relationships
+$member->user;
+$member->team;
+$member->inviter;
+
+// Permission methods
+$member->getEffectivePermissions(); // Team + custom permissions
+$member->hasPermission('social.write');
+$member->hasAnyPermission(['admin', 'owner']);
+$member->hasAllPermissions(['social.read', 'social.write']);
+
+// Role checks
+$member->isOwner();
+$member->isAdmin();
+```
+
+### Permission Resolution
+
+Effective permissions are resolved in order:
+
+1. **Role-based**: Owner role grants `*`, Admin role grants admin permissions
+2. **Team permissions**: Permissions from assigned team
+3. **Custom permissions**: If set, completely override team permissions
+
+```php
+public function getEffectivePermissions(): array
+{
+ // 1. Owner has all permissions
+ if ($this->isOwner()) {
+ return ['*'];
+ }
+
+ // 2. Custom permissions override team
+ if (!empty($this->custom_permissions)) {
+ return $this->custom_permissions;
+ }
+
+ // 3. Team permissions
+ return $this->team?->permissions ?? [];
+}
+```
+
+## Workspace Invitations
+
+### Invite Users
+
+```php
+// Via Workspace model
+$invitation = $workspace->invite(
+ email: 'newuser@example.com',
+ role: 'member',
+ invitedBy: $currentUser,
+ expiresInDays: 7
+);
+
+// Invitation sent via WorkspaceInvitationNotification
+```
+
+### Accept Invitation
+
+```php
+// Find and accept
+$invitation = WorkspaceInvitation::findPendingByToken($token);
+
+if ($invitation && $invitation->accept($user)) {
+ // User added to workspace
+}
+
+// Or via Workspace static method
+Workspace::acceptInvitation($token, $user);
+```
+
+### Invitation States
+
+```php
+$invitation->isPending(); // Not accepted, not expired
+$invitation->isExpired(); // Past expires_at
+$invitation->isAccepted(); // Has accepted_at
+```
+
+## Custom Teams
+
+### Creating Custom Teams
+
+```php
+$team = $teamService->createTeam([
+ 'name' => 'Social Media Managers',
+ 'slug' => 'social-managers',
+ 'description' => 'Team for managing social media accounts',
+ 'permissions' => [
+ 'workspace.read',
+ 'social.read',
+ 'social.write',
+ 'social.delete',
+ 'analytics.read',
+ ],
+ 'colour' => 'purple',
+ 'is_default' => false,
+]);
+```
+
+### Making Team Default
+
+```php
+$teamService->updateTeam($team, ['is_default' => true]);
+// Other teams automatically have is_default set to false
+```
+
+### Deleting Teams
+
+```php
+// Only non-system teams can be deleted
+// Teams with members cannot be deleted
+
+if ($team->is_system) {
+ throw new \RuntimeException('Cannot delete system teams');
+}
+
+if ($teamService->countTeamMembers($team) > 0) {
+ throw new \RuntimeException('Remove members first');
+}
+
+$teamService->deleteTeam($team);
+```
+
+## Seeding Default Teams
+
+When creating a new workspace:
+
+```php
+$teamService->forWorkspace($workspace);
+$teams = $teamService->seedDefaultTeams();
+
+// Or ensure they exist (idempotent)
+$teams = $teamService->ensureDefaultTeams();
+```
+
+### Migrating Existing Members
+
+If migrating from role-based to team-based:
+
+```php
+$migrated = $teamService->migrateExistingMembers();
+// Assigns members to teams based on their role:
+// owner -> Owners team
+// admin -> Admins team
+// member -> Members team
+```
+
+## Best Practices
+
+### Use Middleware for Route Protection
+
+```php
+Route::middleware(['auth', 'workspace.required', 'workspace.permission:social.write'])
+ ->group(function () {
+ Route::resource('posts', PostController::class);
+ });
+```
+
+### Check Permissions in Controllers
+
+```php
+public function store(Request $request)
+{
+ $teamService = app(WorkspaceTeamService::class);
+ $teamService->forWorkspace($request->attributes->get('workspace_model'));
+
+ if (!$teamService->hasPermission($request->user(), 'social.write')) {
+ abort(403, 'You do not have permission to create posts');
+ }
+
+ // ...
+}
+```
+
+### Use Policies with Teams
+
+```php
+class PostPolicy
+{
+ public function create(User $user): bool
+ {
+ $teamService = app(WorkspaceTeamService::class);
+ return $teamService->hasPermission($user, 'social.write');
+ }
+
+ public function delete(User $user, Post $post): bool
+ {
+ $teamService = app(WorkspaceTeamService::class);
+ return $teamService->hasPermission($user, 'social.delete');
+ }
+}
+```
+
+### Permission Naming Conventions
+
+Follow the pattern: `service.action`
+
+- `service.read` - View resources
+- `service.write` - Create/edit resources
+- `service.delete` - Delete resources
+- `workspace.manage_*` - Workspace admin actions
diff --git a/docs/patterns.md b/docs/patterns.md
new file mode 100644
index 0000000..de2d66e
--- /dev/null
+++ b/docs/patterns.md
@@ -0,0 +1,1336 @@
+# Core PHP Framework Patterns
+
+This guide covers the key architectural patterns used throughout the Core PHP Framework. Each pattern includes guidance on when to use it, quick start examples, and common pitfalls to avoid.
+
+## Table of Contents
+
+1. [Actions Pattern](#1-actions-pattern)
+2. [Multi-Tenant Data Isolation](#2-multi-tenant-data-isolation)
+3. [Module System (Lifecycle Events)](#3-module-system-lifecycle-events)
+4. [Activity Logging](#4-activity-logging)
+5. [Seeder Auto-Discovery](#5-seeder-auto-discovery)
+6. [Service Definition](#6-service-definition)
+
+---
+
+## 1. Actions Pattern
+
+**Location:** `packages/core-php/src/Core/Actions/`
+
+Actions are single-purpose classes that encapsulate business logic. They extract complex operations from controllers and Livewire components into testable, reusable units.
+
+### When to Use
+
+- Business operations with multiple steps (validation, authorization, persistence, side effects)
+- Logic that should be reusable across controllers, commands, and API endpoints
+- Operations that benefit from dependency injection
+- Any operation complex enough to warrant unit testing in isolation
+
+### When NOT to Use
+
+- Simple CRUD operations that don't need validation beyond form requests
+- One-line operations that don't benefit from abstraction
+
+### Quick Start
+
+```php
+posts->create([
+ 'user_id' => $user->id,
+ 'title' => $data['title'],
+ 'content' => $data['content'],
+ ]);
+
+ if (isset($data['image'])) {
+ $this->images->attach($post, $data['image']);
+ }
+
+ return $post;
+ }
+}
+```
+
+**Usage:**
+
+```php
+// Via dependency injection (preferred)
+public function __construct(private CreatePost $createPost) {}
+
+$post = $this->createPost->handle($user, $validated);
+
+// Via static helper
+$post = CreatePost::run($user, $validated);
+
+// Via container
+$post = app(CreatePost::class)->handle($user, $validated);
+```
+
+### Full Example
+
+Here's an Action that creates a project with entitlement checking:
+
+```php
+defaultWorkspace();
+
+ // Check entitlements
+ if ($workspace) {
+ $this->checkEntitlements($workspace);
+ }
+
+ // Generate unique slug if not provided
+ $data['slug'] = $data['slug'] ?? $this->generateUniqueSlug($data['name']);
+ $data['user_id'] = $user->id;
+ $data['workspace_id'] = $data['workspace_id'] ?? $workspace?->id;
+
+ // Create the project
+ $project = Project::create($data);
+
+ // Record usage
+ if ($workspace) {
+ $this->entitlements->recordUsage(
+ $workspace,
+ 'projects.count',
+ 1,
+ $user,
+ ['project_id' => $project->id]
+ );
+ }
+
+ // Log activity
+ Activity::causedBy($user)
+ ->performedOn($project)
+ ->withProperties(['name' => $project->name])
+ ->log('created');
+
+ return $project;
+ }
+
+ public static function run(User $user, array $data): Project
+ {
+ return app(static::class)->handle($user, $data);
+ }
+
+ protected function checkEntitlements(Workspace $workspace): void
+ {
+ $result = $this->entitlements->can($workspace, 'projects.count');
+
+ if ($result->isDenied()) {
+ throw new EntitlementException(
+ "You have reached your project limit. Please upgrade your plan.",
+ 'projects.count'
+ );
+ }
+ }
+
+ protected function generateUniqueSlug(string $name): string
+ {
+ $slug = \Illuminate\Support\Str::slug($name);
+ $original = $slug;
+ $counter = 1;
+
+ while (Project::where('slug', $slug)->exists()) {
+ $slug = $original . '-' . $counter++;
+ }
+
+ return $slug;
+ }
+}
+```
+
+### Directory Structure
+
+```
+Mod/Example/Actions/
+├── CreateThing.php
+├── UpdateThing.php
+├── DeleteThing.php
+└── Thing/
+ ├── PublishThing.php
+ └── ArchiveThing.php
+```
+
+### Configuration
+
+Actions use Laravel's service container for dependency injection. No additional configuration is required.
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Too many responsibilities | Keep actions focused on one operation. Split into multiple actions if needed. |
+| Returning void | Always return something useful (the created/updated model, a result DTO). |
+| Not using dependency injection | Inject dependencies via constructor, not `app()` calls inside methods. |
+| Catching all exceptions | Let exceptions bubble up for proper error handling. |
+| Direct database queries | Use repositories or model methods for testability. |
+
+---
+
+## 2. Multi-Tenant Data Isolation
+
+**Location:** `packages/core-php/src/Mod/Tenant/`
+
+The multi-tenant system ensures data isolation between workspaces using global scopes and traits. This is a **security-critical** pattern that prevents cross-tenant data leakage.
+
+### When to Use
+
+- Any model that "belongs" to a workspace and should be isolated
+- Data that must never be visible across tenant boundaries
+- Resources that should be automatically scoped to the current workspace context
+
+### Key Components
+
+| Component | Purpose |
+|-----------|---------|
+| `BelongsToWorkspace` trait | Add to models that belong to a workspace |
+| `WorkspaceScope` | Global scope that filters queries |
+| `MissingWorkspaceContextException` | Security exception when context is missing |
+
+### Quick Start
+
+```php
+where('is_active', true);
+ }
+}
+```
+
+**Using the model:**
+
+```php
+// Automatically scoped to current workspace
+$products = Product::all();
+$product = Product::where('sku', 'ABC123')->first();
+$activeProducts = Product::active()->get();
+
+// Creating - workspace_id is auto-assigned
+$product = Product::create(['name' => 'Widget', 'sku' => 'W001']);
+
+// Cached collection for current workspace
+$products = Product::ownedByCurrentWorkspaceCached();
+
+// Query a specific workspace (admin use)
+$products = Product::forWorkspace($workspace)->get();
+
+// Query across all workspaces (admin use - be careful!)
+$allProducts = Product::acrossWorkspaces()->get();
+```
+
+### Strict Mode vs Permissive Mode
+
+**Strict mode (default):** Throws `MissingWorkspaceContextException` when workspace context is unavailable. This is the secure default.
+
+```php
+// In strict mode, this throws an exception if no workspace context
+$products = Product::all(); // MissingWorkspaceContextException
+```
+
+**Permissive mode:** Returns empty results instead of throwing. Use sparingly.
+
+```php
+// Disable strict mode globally (not recommended)
+WorkspaceScope::disableStrictMode();
+
+// Disable for a specific callback
+WorkspaceScope::withoutStrictMode(function () {
+ // Operations here return empty results if no context
+ $products = Product::all(); // Returns empty collection
+});
+
+// Disable for a specific model
+class LegacyProduct extends Model
+{
+ use BelongsToWorkspace;
+
+ protected bool $workspaceScopeStrict = false; // Not recommended
+}
+```
+
+### When to Use `forWorkspace()` vs `acrossWorkspaces()`
+
+| Method | Use Case |
+|--------|----------|
+| `forWorkspace($workspace)` | Admin viewing a specific workspace's data |
+| `acrossWorkspaces()` | Global reports, admin dashboards, CLI commands |
+| Neither (default) | Normal application code - uses current context |
+
+**Always prefer the default scoping** unless you have a specific reason to query across workspaces.
+
+### Security Considerations
+
+1. **Never disable strict mode globally** in production
+2. **Audit uses of `acrossWorkspaces()`** - each use should be justified
+3. **CLI commands** automatically bypass strict mode (they have no request context)
+4. **Test data isolation** - write tests that verify cross-tenant queries fail
+5. **The `workspace_id` column must exist** on all tenant-scoped tables
+
+### Configuration
+
+```php
+// In migrations - always add workspace_id
+Schema::create('products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ // ...
+ $table->index('workspace_id'); // Important for performance
+});
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Forgetting `workspace_id` in migrations | Always add the foreign key and index |
+| Using `acrossWorkspaces()` casually | Audit every usage, prefer default scoping |
+| Suppressing the exception | Don't catch `MissingWorkspaceContextException` - fix the context |
+| Direct DB queries | Use Eloquent models so scopes apply |
+| Joining across tenant boundaries | Ensure joins respect workspace_id |
+
+---
+
+## 3. Module System (Lifecycle Events)
+
+**Location:** `packages/core-php/src/Core/Events/`, `packages/core-php/src/Core/Module/`
+
+The module system uses lifecycle events for lazy-loading modules. Modules declare what events they listen to, and are only instantiated when those events fire.
+
+### When to Use
+
+- Creating a new feature module
+- Registering routes, views, Livewire components
+- Adding admin panel navigation
+- Registering console commands
+
+### How It Works
+
+```
+Application Start
+ │
+ ▼
+ModuleScanner scans for Boot.php files with $listens arrays
+ │
+ ▼
+ModuleRegistry registers lazy listeners for each event-module pair
+ │
+ ▼
+Request comes in → Appropriate lifecycle event fires
+ │
+ ▼
+LazyModuleListener instantiates module and calls handler
+ │
+ ▼
+Module registers its resources (routes, views, etc.)
+```
+
+### Quick Start
+
+Create a `Boot.php` file in your module directory:
+
+```php
+ 'onWebRoutes',
+ ];
+
+ public function onWebRoutes(WebRoutesRegistering $event): void
+ {
+ $event->views('example', __DIR__.'/Views');
+ $event->routes(fn () => require __DIR__.'/Routes/web.php');
+ }
+}
+```
+
+### Available Lifecycle Events
+
+| Event | Context | When It Fires |
+|-------|---------|---------------|
+| `WebRoutesRegistering` | Web requests | Public frontend routes |
+| `AdminPanelBooting` | Admin requests | Admin panel setup |
+| `ApiRoutesRegistering` | API requests | REST API routes |
+| `ClientRoutesRegistering` | Client dashboard | Authenticated client routes |
+| `ConsoleBooting` | CLI commands | Artisan booting |
+| `QueueWorkerBooting` | Queue workers | Queue worker starting |
+| `McpToolsRegistering` | MCP server | MCP tool registration |
+| `FrameworkBooted` | All contexts | After all context-specific events |
+
+### Full Example
+
+A complete module Boot class:
+
+```php
+ 'onAdminPanel',
+ ApiRoutesRegistering::class => 'onApiRoutes',
+ WebRoutesRegistering::class => 'onWebRoutes',
+ ConsoleBooting::class => 'onConsole',
+ ];
+
+ public function register(): void
+ {
+ // Register service bindings
+ $this->app->singleton(
+ Services\InventoryService::class,
+ Services\InventoryService::class
+ );
+ }
+
+ public function boot(): void
+ {
+ // Load migrations
+ $this->loadMigrationsFrom(__DIR__.'/Migrations');
+ $this->loadTranslationsFrom(__DIR__.'/Lang/en', 'inventory');
+ }
+
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+
+ // Navigation
+ $event->navigation([
+ 'label' => 'Inventory',
+ 'icon' => 'box',
+ 'route' => 'admin.inventory.index',
+ 'sort_order' => 50,
+ ]);
+
+ // Livewire components
+ $event->livewire('inventory-list', View\Livewire\InventoryList::class);
+ $event->livewire('product-form', View\Livewire\ProductForm::class);
+ }
+
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ $event->routes(fn () => Route::middleware('api')
+ ->prefix('v1/inventory')
+ ->group(__DIR__.'/Routes/api.php'));
+ }
+
+ public function onWebRoutes(WebRoutesRegistering $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+ $event->routes(fn () => Route::middleware('web')
+ ->group(__DIR__.'/Routes/web.php'));
+ }
+
+ public function onConsole(ConsoleBooting $event): void
+ {
+ $event->command(Console\SyncInventoryCommand::class);
+ $event->command(Console\PruneStaleStockCommand::class);
+ }
+}
+```
+
+### Available Request Methods
+
+Events provide these methods for registering resources:
+
+| Method | Purpose |
+|--------|---------|
+| `routes(callable)` | Register route files/callbacks |
+| `views(namespace, path)` | Register view namespaces |
+| `livewire(alias, class)` | Register Livewire components |
+| `middleware(alias, class)` | Register middleware aliases |
+| `command(class)` | Register Artisan commands |
+| `translations(namespace, path)` | Register translation namespaces |
+| `bladeComponentPath(path, namespace)` | Register anonymous Blade components |
+| `policy(model, policy)` | Register model policies |
+| `navigation(item)` | Register navigation items |
+
+### The Request/Collect Pattern
+
+Events use a "request/collect" pattern:
+
+1. **Modules request** resources via methods like `routes()`, `views()`
+2. **Requests are collected** in arrays during event dispatch
+3. **LifecycleEventProvider processes** all requests with validation
+
+This ensures modules don't directly mutate infrastructure and allows central validation.
+
+### Module Directory Structure
+
+```
+Mod/Inventory/
+├── Boot.php # Module entry point
+├── Routes/
+│ ├── web.php
+│ └── api.php
+├── View/
+│ ├── Blade/ # Blade templates
+│ │ └── admin/
+│ └── Livewire/ # Livewire components
+├── Models/
+├── Actions/
+├── Services/
+├── Console/
+├── Migrations/
+└── Lang/
+```
+
+### Configuration
+
+Namespace detection is automatic based on path:
+- `/Core` paths → `Core\` namespace
+- `/Mod` paths → `Mod\` namespace
+- `/Website` paths → `Website\` namespace
+- `/Plug` paths → `Plug\` namespace
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Registering routes outside events | Always use `$event->routes()` in handlers |
+| Heavy work in `$listens` handlers | Keep handlers lightweight; defer work |
+| Forgetting to add `$listens` | Module won't load without static `$listens` |
+| Using wrong event | Match event to request context (web, api, admin) |
+| Instantiating in `$listens` | The array is read statically; don't call methods |
+
+---
+
+## 4. Activity Logging
+
+**Location:** `packages/core-php/src/Core/Activity/`
+
+Activity logging tracks model changes and user actions. Built on spatie/laravel-activitylog with workspace-aware enhancements.
+
+### When to Use
+
+- Audit trails for compliance
+- User activity history
+- Model change tracking
+- Debugging and support
+
+### Quick Start
+
+Add the trait to any model:
+
+```php
+properties = $activity->properties->merge([
+ 'contract_number' => $this->contract_number,
+ 'client_id' => $this->client_id,
+ ]);
+ }
+}
+```
+
+**Querying activity logs:**
+
+```php
+use Core\Activity\Services\ActivityLogService;
+
+$service = app(ActivityLogService::class);
+
+// Get activities for a specific model
+$activities = $service->logFor($contract)->recent(20);
+
+// Get activities by a user in a workspace
+$activities = $service
+ ->logBy($user)
+ ->forWorkspace($workspace)
+ ->lastDays(7)
+ ->paginate(25);
+
+// Get all "updated" events for a model type
+$activities = $service
+ ->forSubjectType(Contract::class)
+ ->ofType('updated')
+ ->between('2024-01-01', '2024-12-31')
+ ->get();
+
+// Search activities
+$results = $service->search('approved contract');
+
+// Get statistics
+$stats = $service->statistics($workspace);
+// Returns: ['total' => 150, 'by_event' => [...], 'by_subject' => [...], 'by_user' => [...]]
+```
+
+**Using the Activity model directly:**
+
+```php
+use Core\Activity\Models\Activity;
+
+// Get activities for a workspace
+$activities = Activity::forWorkspace($workspace)->newest()->get();
+
+// Filter by event type
+$created = Activity::createdEvents()->lastDays(30)->get();
+$deleted = Activity::deletedEvents()->withDeletedSubject()->get();
+
+// Get activities with changes
+$withChanges = Activity::withChanges()->get();
+
+// Access change details
+foreach ($activities as $activity) {
+ echo $activity->description;
+ echo $activity->causer_name; // "John Doe" or "System"
+ echo $activity->subject_name; // "Contract #123"
+
+ // Get specific changes
+ foreach ($activity->changes as $field => $values) {
+ echo "{$field}: {$values['old']} -> {$values['new']}";
+ }
+}
+```
+
+### Configuration
+
+Configure via model properties:
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `$activityLogAttributes` | array | null (all) | Attributes to log |
+| `$activityLogName` | string | 'default' | Log name for grouping |
+| `$activityLogEvents` | array | ['created', 'updated', 'deleted'] | Events to log |
+| `$activityLogWorkspace` | bool | true | Include workspace_id |
+| `$activityLogOnlyDirty` | bool | true | Only log changed attributes |
+
+**Global configuration (config/core.php):**
+
+```php
+'activity' => [
+ 'enabled' => env('ACTIVITY_LOG_ENABLED', true),
+ 'log_name' => 'default',
+ 'include_workspace' => true,
+ 'default_events' => ['created', 'updated', 'deleted'],
+ 'retention_days' => 90,
+],
+```
+
+### Temporarily Disabling Logging
+
+```php
+// Disable for a callback
+Document::withoutActivityLogging(function () {
+ $document->update(['status' => 'processed']);
+});
+
+// Check if logging is enabled
+if (Document::activityLoggingEnabled()) {
+ // ...
+}
+```
+
+### Pruning Old Logs
+
+```bash
+# Via artisan command
+php artisan activity:prune --days=90
+
+# Via service
+$service = app(ActivityLogService::class);
+$deleted = $service->prune(90); // Delete logs older than 90 days
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Logging sensitive data | Exclude sensitive fields via `$activityLogAttributes` |
+| Excessive logging | Log only meaningful changes, not every field |
+| Performance issues | Use `withoutActivityLogging()` for bulk operations |
+| Missing workspace context | Ensure workspace is set before logging |
+| Large properties | Limit logged data; don't log file contents |
+
+---
+
+## 5. Seeder Auto-Discovery
+
+**Location:** `packages/core-php/src/Core/Database/Seeders/`
+
+The seeder auto-discovery system scans module directories for seeders and executes them in the correct order based on priority and dependencies.
+
+### When to Use
+
+- Creating seeders for a module
+- Seeding reference data (features, packages, configuration)
+- Establishing dependencies between seeders
+- Demo/test data setup
+
+### Quick Start
+
+Create a seeder in your module's `Database/Seeders/` directory:
+
+```php
+ 'example.feature'],
+ ['name' => 'Example Feature', 'type' => 'boolean']
+ );
+ }
+}
+```
+
+Seeders are discovered automatically - no manual registration required.
+
+### Priority and Dependencies
+
+**Using priority (simple ordering):**
+
+```php
+// Using class property
+class FeatureSeeder extends Seeder
+{
+ public int $priority = 10; // Runs early
+}
+
+// Using attribute
+use Core\Database\Seeders\Attributes\SeederPriority;
+
+#[SeederPriority(10)]
+class FeatureSeeder extends Seeder
+{
+ public function run(): void { /* ... */ }
+}
+```
+
+**Priority guidelines:**
+
+| Range | Use Case |
+|-------|----------|
+| 0-20 | Foundation (features, config) |
+| 20-40 | Core data (packages, workspaces) |
+| 40-60 | Default (general seeders) |
+| 60-80 | Content (pages, posts) |
+| 80-100 | Demo/test data |
+
+**Using dependencies (explicit ordering):**
+
+```php
+use Core\Database\Seeders\Attributes\SeederAfter;
+use Core\Database\Seeders\Attributes\SeederBefore;
+use Core\Mod\Tenant\Database\Seeders\FeatureSeeder;
+
+// This seeder runs AFTER FeatureSeeder completes
+#[SeederAfter(FeatureSeeder::class)]
+class PackageSeeder extends Seeder
+{
+ public function run(): void
+ {
+ // Can safely reference features created by FeatureSeeder
+ }
+}
+
+// This seeder runs BEFORE PackageSeeder
+#[SeederBefore(PackageSeeder::class)]
+class FeatureSeeder extends Seeder
+{
+ public function run(): void
+ {
+ // Features are created first
+ }
+}
+```
+
+**Multiple dependencies:**
+
+```php
+#[SeederAfter(FeatureSeeder::class, PackageSeeder::class)]
+class WorkspaceSeeder extends Seeder
+{
+ // Runs after both FeatureSeeder and PackageSeeder
+}
+```
+
+### Full Example
+
+```php
+ 'free',
+ 'name' => 'Free',
+ 'price_monthly' => 0,
+ 'features' => ['basic.access'],
+ 'sort_order' => 1,
+ ],
+ [
+ 'code' => 'pro',
+ 'name' => 'Professional',
+ 'price_monthly' => 29,
+ 'features' => ['basic.access', 'advanced.features', 'support.priority'],
+ 'sort_order' => 2,
+ ],
+ [
+ 'code' => 'enterprise',
+ 'name' => 'Enterprise',
+ 'price_monthly' => 99,
+ 'features' => ['basic.access', 'advanced.features', 'support.priority', 'api.access'],
+ 'sort_order' => 3,
+ ],
+ ];
+
+ foreach ($plans as $planData) {
+ Plan::updateOrCreate(
+ ['code' => $planData['code']],
+ $planData
+ );
+ }
+
+ $this->command->info('Billing plans seeded successfully.');
+ }
+}
+```
+
+### How Discovery Works
+
+1. `SeederDiscovery` scans configured paths for `*Seeder.php` files
+2. Files are read to extract namespace and class name
+3. Priority and dependency attributes/properties are parsed
+4. Seeders are topologically sorted (dependencies first, then by priority)
+5. `CoreDatabaseSeeder` executes them in order
+
+### Configuration
+
+The discovery scans these paths by default:
+- `app/Core/*/Database/Seeders/`
+- `app/Mod/*/Database/Seeders/`
+- `packages/core-php/src/Core/*/Database/Seeders/`
+- `packages/core-php/src/Mod/*/Database/Seeders/`
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Circular dependencies | Review dependencies; use priority instead if possible |
+| Missing table errors | Check `Schema::hasTable()` before seeding |
+| Non-idempotent seeders | Use `updateOrCreate()` or `firstOrCreate()` |
+| Hardcoded IDs | Use codes/slugs for lookups, not numeric IDs |
+| Dependencies on non-existent seeders | Ensure dependent seeders are discoverable |
+| Forgetting to output progress | Use `$this->command->info()` for visibility |
+
+---
+
+## 6. Service Definition
+
+**Location:** `packages/core-php/src/Core/Service/`
+
+Services are the product layer of the framework. They define how modules are presented as SaaS products with versioning, health checks, and dependency management.
+
+### When to Use
+
+- Defining a new SaaS product/service
+- Adding health monitoring to a service
+- Declaring service dependencies
+- Managing service lifecycle (deprecation, sunset)
+
+### Key Components
+
+| Component | Purpose |
+|-----------|---------|
+| `ServiceDefinition` | Interface for service definitions |
+| `ServiceVersion` | Semantic versioning with deprecation |
+| `ServiceDependency` | Declare dependencies on other services |
+| `HealthCheckable` | Interface for health monitoring |
+| `HealthCheckResult` | Health check response object |
+
+### Quick Start
+
+```php
+ 'billing',
+ 'module' => 'Mod\\Billing',
+ 'name' => 'Billing',
+ 'tagline' => 'Subscription management',
+ 'icon' => 'credit-card',
+ 'color' => '#10B981',
+ 'entitlement_code' => 'core.srv.billing',
+ 'sort_order' => 20,
+ ];
+ }
+
+ public static function version(): ServiceVersion
+ {
+ return new ServiceVersion(1, 0, 0);
+ }
+
+ public static function dependencies(): array
+ {
+ return [];
+ }
+
+ // From AdminMenuProvider interface
+ public function menuItems(): array
+ {
+ return [
+ [
+ 'label' => 'Billing',
+ 'icon' => 'credit-card',
+ 'route' => 'admin.billing.index',
+ ],
+ ];
+ }
+}
+```
+
+### Full Example
+
+A complete service with health checks and dependencies:
+
+```php
+ 'analytics',
+ 'module' => 'Mod\\Analytics',
+ 'name' => 'AnalyticsHost',
+ 'tagline' => 'Privacy-first website analytics',
+ 'description' => 'Lightweight, GDPR-compliant analytics without cookies.',
+ 'icon' => 'chart-line',
+ 'color' => '#F59E0B',
+ 'entitlement_code' => 'core.srv.analytics',
+ 'sort_order' => 30,
+ ];
+ }
+
+ public static function version(): ServiceVersion
+ {
+ return new ServiceVersion(2, 1, 0);
+ }
+
+ public static function dependencies(): array
+ {
+ return [
+ ServiceDependency::required('auth', '>=1.0.0'),
+ ServiceDependency::optional('billing'),
+ ];
+ }
+
+ public function healthCheck(): HealthCheckResult
+ {
+ try {
+ $start = microtime(true);
+
+ // Check ClickHouse connection
+ $this->clickhouse->select('SELECT 1');
+
+ // Check Redis connection
+ $this->redis->ping();
+
+ $responseTime = (microtime(true) - $start) * 1000;
+
+ if ($responseTime > 1000) {
+ return HealthCheckResult::degraded(
+ 'Database responding slowly',
+ ['response_time_ms' => $responseTime]
+ );
+ }
+
+ return HealthCheckResult::healthy(
+ 'All systems operational',
+ [],
+ $responseTime
+ );
+ } catch (\Exception $e) {
+ return HealthCheckResult::fromException($e);
+ }
+ }
+
+ public function menuItems(): array
+ {
+ return [
+ [
+ 'label' => 'Analytics',
+ 'icon' => 'chart-line',
+ 'route' => 'admin.analytics.dashboard',
+ 'children' => [
+ ['label' => 'Dashboard', 'route' => 'admin.analytics.dashboard'],
+ ['label' => 'Sites', 'route' => 'admin.analytics.sites'],
+ ['label' => 'Reports', 'route' => 'admin.analytics.reports'],
+ ],
+ ],
+ ];
+ }
+}
+```
+
+### Service Versioning
+
+```php
+use Core\Service\ServiceVersion;
+
+// Create a version
+$version = new ServiceVersion(2, 1, 0);
+echo $version; // "2.1.0"
+
+// Parse from string
+$version = ServiceVersion::fromString('v2.1.0');
+
+// Check compatibility
+$minimum = new ServiceVersion(1, 5, 0);
+$current = new ServiceVersion(1, 8, 2);
+$current->isCompatibleWith($minimum); // true
+
+// Mark as deprecated
+$version = (new ServiceVersion(1, 0, 0))
+ ->deprecate(
+ 'Use v2.x instead. See docs/migration.md',
+ new \DateTimeImmutable('2025-06-01')
+ );
+
+// Check deprecation status
+if ($version->deprecated) {
+ echo $version->deprecationMessage;
+}
+
+if ($version->isPastSunset()) {
+ throw new ServiceSunsetException('Service no longer available');
+}
+```
+
+### Service Dependencies
+
+```php
+use Core\Service\Contracts\ServiceDependency;
+
+public static function dependencies(): array
+{
+ return [
+ // Required with minimum version
+ ServiceDependency::required('auth', '>=1.0.0'),
+
+ // Required with version range
+ ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
+
+ // Optional dependency
+ ServiceDependency::optional('analytics'),
+ ];
+}
+```
+
+### Health Checks
+
+```php
+use Core\Service\Contracts\HealthCheckable;
+use Core\Service\HealthCheckResult;
+
+class MyService implements ServiceDefinition, HealthCheckable
+{
+ public function healthCheck(): HealthCheckResult
+ {
+ // Healthy
+ return HealthCheckResult::healthy('All systems operational');
+
+ // Healthy with response time
+ return HealthCheckResult::healthy(
+ 'Service operational',
+ ['connections' => 5],
+ responseTimeMs: 45.2
+ );
+
+ // Degraded (works but with issues)
+ return HealthCheckResult::degraded(
+ 'High latency detected',
+ ['latency_ms' => 1500]
+ );
+
+ // Unhealthy
+ return HealthCheckResult::unhealthy(
+ 'Database connection failed',
+ ['last_error' => 'Connection refused']
+ );
+
+ // From exception
+ try {
+ $this->checkCriticalSystem();
+ } catch (\Exception $e) {
+ return HealthCheckResult::fromException($e);
+ }
+ }
+}
+```
+
+### Health Check Guidelines
+
+Health checks should be:
+
+| Guideline | Recommendation |
+|-----------|----------------|
+| Fast | Complete within 5 seconds (< 1 second preferred) |
+| Non-destructive | Read-only operations only |
+| Representative | Test actual critical dependencies |
+| Safe | Handle all exceptions, return HealthCheckResult |
+
+### Configuration
+
+Service definitions populate the `platform_services` table:
+
+```php
+// definition() return array
+[
+ 'code' => 'billing', // Unique identifier (required)
+ 'module' => 'Mod\\Billing', // Module namespace (required)
+ 'name' => 'Billing', // Display name (required)
+ 'tagline' => 'Subscription management', // Short description
+ 'description' => '...', // Full description
+ 'icon' => 'link', // FontAwesome icon
+ 'color' => '#3B82F6', // Brand color
+ 'entitlement_code' => '...', // Access control code
+ 'sort_order' => 10, // Menu ordering
+]
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Slow health checks | Keep under 1 second; test critical paths only |
+| Circular dependencies | Review service architecture; refactor if needed |
+| Missing version() | Always implement; return `ServiceVersion::initial()` at minimum |
+| Health check exceptions | Catch all exceptions; return `fromException()` |
+| Forgetting dependencies | Document all service interdependencies |
+
+---
+
+## Summary
+
+These patterns form the backbone of the Core PHP Framework:
+
+| Pattern | Purpose | Key Files |
+|---------|---------|-----------|
+| Actions | Encapsulate business logic | `Core\Actions\Action` |
+| Multi-Tenant | Data isolation between workspaces | `BelongsToWorkspace`, `WorkspaceScope` |
+| Module System | Lazy-loading via lifecycle events | `Boot.php`, `$listens` |
+| Activity Logging | Audit trail and change tracking | `LogsActivity`, `ActivityLogService` |
+| Seeder Discovery | Auto-discovered, ordered seeding | `#[SeederPriority]`, `#[SeederAfter]` |
+| Service Definition | SaaS product layer | `ServiceDefinition`, `HealthCheckable` |
+
+For more details, explore the source files in their respective locations or check the inline documentation.
diff --git a/docs/public/CNAME b/docs/public/CNAME
new file mode 100644
index 0000000..008ea5a
--- /dev/null
+++ b/docs/public/CNAME
@@ -0,0 +1 @@
+core.help
diff --git a/docs/publish/aur.md b/docs/publish/aur.md
new file mode 100644
index 0000000..63d92e9
--- /dev/null
+++ b/docs/publish/aur.md
@@ -0,0 +1,91 @@
+# AUR
+
+Publish to the Arch User Repository for Arch Linux users.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: aur
+ package: myapp-bin
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `package` | AUR package name | `{project}-bin` |
+| `maintainer` | Maintainer name | From git config |
+| `description` | Package description | From project |
+| `license` | License identifier | Auto-detected |
+| `depends` | Runtime dependencies | `[]` |
+| `optdepends` | Optional dependencies | `[]` |
+
+## Examples
+
+### Basic Package
+
+```yaml
+publishers:
+ - type: aur
+ package: core-bin
+```
+
+### With Dependencies
+
+```yaml
+publishers:
+ - type: aur
+ package: core-bin
+ depends:
+ - git
+ - docker
+ optdepends:
+ - "podman: alternative container runtime"
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `AUR_SSH_KEY` | SSH private key for AUR push (required) |
+
+## Setup
+
+1. Create an AUR account at https://aur.archlinux.org
+
+2. Add your SSH public key to your AUR account
+
+3. Create the initial package:
+ ```bash
+ git clone ssh://aur@aur.archlinux.org/myapp-bin.git
+ ```
+
+4. After publishing, users install with:
+ ```bash
+ yay -S myapp-bin
+ # or
+ paru -S myapp-bin
+ ```
+
+## Generated PKGBUILD
+
+```bash
+# Maintainer: Your Name
+pkgname=myapp-bin
+pkgver=1.2.3
+pkgrel=1
+pkgdesc="CLI for building and deploying applications"
+arch=('x86_64' 'aarch64')
+url="https://github.com/org/myapp"
+license=('MIT')
+depends=('glibc')
+source_x86_64=("${url}/releases/download/v${pkgver}/myapp_linux_amd64.tar.gz")
+source_aarch64=("${url}/releases/download/v${pkgver}/myapp_linux_arm64.tar.gz")
+sha256sums_x86_64=('abc123...')
+sha256sums_aarch64=('def456...')
+
+package() {
+ install -Dm755 myapp "${pkgdir}/usr/bin/myapp"
+}
+```
\ No newline at end of file
diff --git a/docs/publish/chocolatey.md b/docs/publish/chocolatey.md
new file mode 100644
index 0000000..2439578
--- /dev/null
+++ b/docs/publish/chocolatey.md
@@ -0,0 +1,87 @@
+# Chocolatey
+
+Publish to Chocolatey for Windows package management.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: chocolatey
+ package: myapp
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `package` | Package ID | Project name |
+| `title` | Display title | Package ID |
+| `description` | Package description | From project |
+| `authors` | Package authors | From git config |
+| `license` | License URL | Auto-detected |
+| `projectUrl` | Project homepage | Repository URL |
+| `iconUrl` | Package icon URL | None |
+| `tags` | Package tags | `[]` |
+
+## Examples
+
+### Basic Package
+
+```yaml
+publishers:
+ - type: chocolatey
+ package: core
+```
+
+### With Metadata
+
+```yaml
+publishers:
+ - type: chocolatey
+ package: core
+ title: "Core CLI"
+ description: "CLI for building and deploying applications"
+ tags:
+ - cli
+ - devops
+ - build
+ iconUrl: https://example.com/icon.png
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `CHOCOLATEY_API_KEY` | Chocolatey API key (required) |
+
+## Setup
+
+1. Create a Chocolatey account at https://community.chocolatey.org
+
+2. Get your API key from your account settings
+
+3. After publishing, users install with:
+ ```powershell
+ choco install myapp
+ ```
+
+## Generated nuspec
+
+```xml
+
+
+
+ myapp
+ 1.2.3
+ Core CLI
+ Host UK
+ CLI for building and deploying applications
+ https://github.com/org/myapp
+ https://github.com/org/myapp/blob/main/LICENSE
+ cli devops build
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/docs/publish/docker.md b/docs/publish/docker.md
new file mode 100644
index 0000000..5e68752
--- /dev/null
+++ b/docs/publish/docker.md
@@ -0,0 +1,93 @@
+# Docker
+
+Push container images to Docker Hub, GitHub Container Registry, AWS ECR, or any OCI-compliant registry.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: docker
+ registry: ghcr.io
+ image: org/myapp
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `registry` | Registry hostname | `docker.io` |
+| `image` | Image name | Project name |
+| `platforms` | Target platforms | `linux/amd64` |
+| `tags` | Image tags | `latest`, version |
+| `dockerfile` | Dockerfile path | `Dockerfile` |
+| `context` | Build context | `.` |
+
+## Examples
+
+### GitHub Container Registry
+
+```yaml
+publishers:
+ - type: docker
+ registry: ghcr.io
+ image: host-uk/myapp
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ tags:
+ - latest
+ - "{{ .Version }}"
+ - "{{ .Major }}.{{ .Minor }}"
+```
+
+### Docker Hub
+
+```yaml
+publishers:
+ - type: docker
+ image: myorg/myapp
+ tags:
+ - latest
+ - "{{ .Version }}"
+```
+
+### AWS ECR
+
+```yaml
+publishers:
+ - type: docker
+ registry: 123456789.dkr.ecr.eu-west-1.amazonaws.com
+ image: myapp
+```
+
+### Multi-Platform Build
+
+```yaml
+publishers:
+ - type: docker
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/v7
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `DOCKER_USERNAME` | Registry username |
+| `DOCKER_PASSWORD` | Registry password or token |
+| `AWS_ACCESS_KEY_ID` | AWS credentials (for ECR) |
+| `AWS_SECRET_ACCESS_KEY` | AWS credentials (for ECR) |
+
+## Tag Templates
+
+| Template | Example |
+|----------|---------|
+| `.Version` | `1.2.3` |
+| `.Major` | `1` |
+| `.Minor` | `2` |
+| `.Patch` | `3` |
+| `.Major` + `.Minor` | `1.2` |
+
+Templates use Go template syntax with double braces.
diff --git a/docs/publish/github.md b/docs/publish/github.md
new file mode 100644
index 0000000..829ba6e
--- /dev/null
+++ b/docs/publish/github.md
@@ -0,0 +1,78 @@
+# GitHub Releases
+
+Publish releases to GitHub with binary assets, checksums, and changelog.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: github
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `draft` | Create as draft release | `false` |
+| `prerelease` | Mark as prerelease | `false` |
+| `assets` | Additional asset patterns | Auto-detected |
+
+## Examples
+
+### Basic Release
+
+```yaml
+publishers:
+ - type: github
+```
+
+Automatically uploads:
+- Built binaries from `dist/`
+- SHA256 checksums
+- Generated changelog
+
+### Draft Release
+
+```yaml
+publishers:
+ - type: github
+ draft: true
+```
+
+### Prerelease
+
+```yaml
+publishers:
+ - type: github
+ prerelease: true
+```
+
+### Custom Assets
+
+```yaml
+publishers:
+ - type: github
+ assets:
+ - dist/*.tar.gz
+ - dist/*.zip
+ - docs/manual.pdf
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `GITHUB_TOKEN` | GitHub personal access token (required) |
+
+## Generated Assets
+
+For a cross-platform Go build, GitHub releases include:
+
+```
+myapp_1.0.0_linux_amd64.tar.gz
+myapp_1.0.0_linux_arm64.tar.gz
+myapp_1.0.0_darwin_amd64.tar.gz
+myapp_1.0.0_darwin_arm64.tar.gz
+myapp_1.0.0_windows_amd64.zip
+checksums.txt
+```
diff --git a/docs/publish/homebrew.md b/docs/publish/homebrew.md
new file mode 100644
index 0000000..f808975
--- /dev/null
+++ b/docs/publish/homebrew.md
@@ -0,0 +1,96 @@
+# Homebrew
+
+Publish to Homebrew for macOS and Linux package management.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: homebrew
+ tap: org/homebrew-tap
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `tap` | Tap repository (`org/homebrew-tap`) | Required |
+| `formula` | Formula name | Project name |
+| `homepage` | Project homepage | Repository URL |
+| `description` | Package description | From project |
+| `license` | License identifier | Auto-detected |
+| `dependencies` | Homebrew dependencies | `[]` |
+
+## Examples
+
+### Basic Formula
+
+```yaml
+publishers:
+ - type: homebrew
+ tap: host-uk/homebrew-tap
+```
+
+### With Dependencies
+
+```yaml
+publishers:
+ - type: homebrew
+ tap: host-uk/homebrew-tap
+ dependencies:
+ - git
+ - go
+```
+
+### Custom Description
+
+```yaml
+publishers:
+ - type: homebrew
+ tap: host-uk/homebrew-tap
+ description: "CLI for building and deploying applications"
+ homepage: https://core.host.uk.com
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `GITHUB_TOKEN` | Token with repo access to tap (required) |
+
+## Setup
+
+1. Create a tap repository: `org/homebrew-tap`
+
+2. Ensure your `GITHUB_TOKEN` has push access to the tap
+
+3. After publishing, users install with:
+ ```bash
+ brew tap org/tap
+ brew install myapp
+ ```
+
+## Generated Formula
+
+```ruby
+class Myapp < Formula
+ desc "CLI for building and deploying applications"
+ homepage "https://github.com/org/myapp"
+ version "1.2.3"
+ license "MIT"
+
+ on_macos do
+ if Hardware::CPU.arm?
+ url "https://github.com/org/myapp/releases/download/v1.2.3/myapp_darwin_arm64.tar.gz"
+ sha256 "abc123..."
+ else
+ url "https://github.com/org/myapp/releases/download/v1.2.3/myapp_darwin_amd64.tar.gz"
+ sha256 "def456..."
+ end
+ end
+
+ def install
+ bin.install "myapp"
+ end
+end
+```
diff --git a/docs/publish/index.md b/docs/publish/index.md
new file mode 100644
index 0000000..b3754d9
--- /dev/null
+++ b/docs/publish/index.md
@@ -0,0 +1,60 @@
+# Publish
+
+Release your applications to package managers, container registries, and distribution platforms.
+
+## Publishers
+
+| Provider | Description |
+|----------|-------------|
+| [GitHub](./github) | GitHub Releases with assets |
+| [Docker](./docker) | Container registries (Docker Hub, GHCR, ECR) |
+| [npm](./npm) | npm registry for JavaScript packages |
+| [Homebrew](./homebrew) | macOS/Linux package manager |
+| [Scoop](./scoop) | Windows package manager |
+| [AUR](./aur) | Arch User Repository |
+| [Chocolatey](./chocolatey) | Windows package manager |
+| [LinuxKit](./linuxkit) | Bootable Linux images |
+
+## Quick Start
+
+```bash
+# 1. Build your artifacts
+core build
+
+# 2. Preview release (dry-run)
+core ci
+
+# 3. Publish (requires explicit flag)
+core ci --we-are-go-for-launch
+```
+
+## Configuration
+
+Publishers are configured in `.core/release.yaml`:
+
+```yaml
+version: 1
+
+project:
+ name: myapp
+ repository: org/myapp
+
+publishers:
+ - type: github
+
+ - type: docker
+ registry: ghcr.io
+ image: org/myapp
+```
+
+## Safety
+
+All publish commands are **dry-run by default**. Use `--we-are-go-for-launch` to actually publish.
+
+```bash
+# Safe preview
+core ci
+
+# Actually publish
+core ci --we-are-go-for-launch
+```
\ No newline at end of file
diff --git a/docs/publish/linuxkit.md b/docs/publish/linuxkit.md
new file mode 100644
index 0000000..2fb63b7
--- /dev/null
+++ b/docs/publish/linuxkit.md
@@ -0,0 +1,116 @@
+# LinuxKit
+
+Build and publish bootable Linux images for VMs, bare metal, and cloud platforms.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: linuxkit
+ config: .core/linuxkit/server.yml
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `config` | LinuxKit YAML config | Required |
+| `formats` | Output formats | `[iso]` |
+| `platforms` | Target platforms | `linux/amd64` |
+| `name` | Image name | From config |
+
+## Formats
+
+| Format | Description |
+|--------|-------------|
+| `iso` | Bootable ISO image |
+| `qcow2` | QEMU/KVM image |
+| `raw` | Raw disk image |
+| `vhd` | Hyper-V image |
+| `vmdk` | VMware image |
+| `aws` | AWS AMI |
+| `gcp` | Google Cloud image |
+| `azure` | Azure VHD |
+
+## Examples
+
+### ISO + QCOW2
+
+```yaml
+publishers:
+ - type: linuxkit
+ config: .core/linuxkit/server.yml
+ formats:
+ - iso
+ - qcow2
+ platforms:
+ - linux/amd64
+ - linux/arm64
+```
+
+### Cloud Images
+
+```yaml
+publishers:
+ - type: linuxkit
+ config: .core/linuxkit/cloud.yml
+ formats:
+ - aws
+ - gcp
+ - azure
+```
+
+### Multiple Configurations
+
+```yaml
+publishers:
+ - type: linuxkit
+ config: .core/linuxkit/minimal.yml
+ formats: [iso]
+ name: myapp-minimal
+
+ - type: linuxkit
+ config: .core/linuxkit/full.yml
+ formats: [iso, qcow2]
+ name: myapp-full
+```
+
+## LinuxKit Config
+
+`.core/linuxkit/server.yml`:
+
+```yaml
+kernel:
+ image: linuxkit/kernel:5.15
+ cmdline: "console=tty0"
+
+init:
+ - linuxkit/init:v0.8
+ - linuxkit/runc:v0.8
+
+onboot:
+ - name: sysctl
+ image: linuxkit/sysctl:v0.8
+ - name: dhcpcd
+ image: linuxkit/dhcpcd:v0.8
+ command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"]
+
+services:
+ - name: myapp
+ image: myapp:latest
+
+files:
+ - path: /etc/myapp/config.yaml
+ contents: |
+ server:
+ port: 8080
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `AWS_ACCESS_KEY_ID` | AWS credentials (for AMI publishing) |
+| `AWS_SECRET_ACCESS_KEY` | AWS credentials |
+| `GOOGLE_APPLICATION_CREDENTIALS` | GCP credentials (for GCP publishing) |
+| `AZURE_CREDENTIALS` | Azure credentials (for Azure publishing) |
\ No newline at end of file
diff --git a/docs/publish/npm.md b/docs/publish/npm.md
new file mode 100644
index 0000000..8425a04
--- /dev/null
+++ b/docs/publish/npm.md
@@ -0,0 +1,77 @@
+# npm
+
+Publish JavaScript/TypeScript packages to the npm registry.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: npm
+ package: "@org/myapp"
+ access: public
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `package` | Package name | From `package.json` |
+| `access` | Access level (`public`, `restricted`) | `restricted` |
+| `tag` | Distribution tag | `latest` |
+| `directory` | Package directory | `.` |
+
+## Examples
+
+### Public Package
+
+```yaml
+publishers:
+ - type: npm
+ package: "@host-uk/cli"
+ access: public
+```
+
+### Scoped Private Package
+
+```yaml
+publishers:
+ - type: npm
+ package: "@myorg/internal-tool"
+ access: restricted
+```
+
+### Beta Release
+
+```yaml
+publishers:
+ - type: npm
+ tag: beta
+```
+
+### Monorepo Package
+
+```yaml
+publishers:
+ - type: npm
+ directory: packages/sdk
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `NPM_TOKEN` | npm access token (required) |
+
+## Setup
+
+1. Create an npm access token:
+ ```bash
+ npm token create --read-only=false
+ ```
+
+2. Add to your CI environment as `NPM_TOKEN`
+
+3. For scoped packages, ensure the scope is linked to your org:
+ ```bash
+ npm login --scope=@myorg
+ ```
diff --git a/docs/publish/scoop.md b/docs/publish/scoop.md
new file mode 100644
index 0000000..647ce6a
--- /dev/null
+++ b/docs/publish/scoop.md
@@ -0,0 +1,77 @@
+# Scoop
+
+Publish to Scoop for Windows package management.
+
+## Configuration
+
+```yaml
+publishers:
+ - type: scoop
+ bucket: org/scoop-bucket
+```
+
+## Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `bucket` | Bucket repository (`org/scoop-bucket`) | Required |
+| `name` | Manifest name | Project name |
+| `homepage` | Project homepage | Repository URL |
+| `description` | Package description | From project |
+| `license` | License identifier | Auto-detected |
+
+## Examples
+
+### Basic Manifest
+
+```yaml
+publishers:
+ - type: scoop
+ bucket: host-uk/scoop-bucket
+```
+
+### With Description
+
+```yaml
+publishers:
+ - type: scoop
+ bucket: host-uk/scoop-bucket
+ description: "CLI for building and deploying applications"
+ homepage: https://core.host.uk.com
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `GITHUB_TOKEN` | Token with repo access to bucket (required) |
+
+## Setup
+
+1. Create a bucket repository: `org/scoop-bucket`
+
+2. Ensure your `GITHUB_TOKEN` has push access to the bucket
+
+3. After publishing, users install with:
+ ```powershell
+ scoop bucket add org https://github.com/org/scoop-bucket
+ scoop install myapp
+ ```
+
+## Generated Manifest
+
+```json
+{
+ "version": "1.2.3",
+ "description": "CLI for building and deploying applications",
+ "homepage": "https://github.com/org/myapp",
+ "license": "MIT",
+ "architecture": {
+ "64bit": {
+ "url": "https://github.com/org/myapp/releases/download/v1.2.3/myapp_windows_amd64.zip",
+ "hash": "sha256:abc123..."
+ }
+ },
+ "bin": "myapp.exe"
+}
+```
\ No newline at end of file
diff --git a/go.mod b/go.mod
deleted file mode 100644
index 6a7620d..0000000
--- a/go.mod
+++ /dev/null
@@ -1,25 +0,0 @@
-module forge.lthn.ai/core/php
-
-go 1.25.5
-
-require (
- forge.lthn.ai/core/go v0.0.0
- github.com/spf13/cobra v1.10.2
- github.com/stretchr/testify v1.11.1
- gopkg.in/yaml.v3 v3.0.1
-)
-
-require (
- github.com/ProtonMail/go-crypto v1.3.0 // indirect
- github.com/cloudflare/circl v1.6.3 // indirect
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
- golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/term v0.40.0 // indirect
- golang.org/x/text v0.34.0 // indirect
-)
-
-replace forge.lthn.ai/core/go => ../go
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 8bdc375..0000000
--- a/go.sum
+++ /dev/null
@@ -1,32 +0,0 @@
-github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
-github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
-github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
-github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
-github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
-github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
-github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
-golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/i18n.go b/i18n.go
deleted file mode 100644
index 96a60a9..0000000
--- a/i18n.go
+++ /dev/null
@@ -1,16 +0,0 @@
-// Package php provides PHP/Laravel development tools.
-package php
-
-import (
- "embed"
-
- "forge.lthn.ai/core/go/pkg/i18n"
-)
-
-//go:embed locales/*.json
-var localeFS embed.FS
-
-func init() {
- // Register PHP translations with the i18n system
- i18n.RegisterLocales(localeFS, "locales")
-}
diff --git a/infection.json5 b/infection.json5
new file mode 100644
index 0000000..5acb831
--- /dev/null
+++ b/infection.json5
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://raw.githubusercontent.com/infection/infection/0.29.0/resources/schema.json",
+ "source": {
+ "directories": ["src"]
+ },
+ "logs": {
+ "text": "infection.log",
+ "summary": "infection-summary.log"
+ },
+ "mutators": {
+ "@default": true
+ },
+ "phpUnit": {
+ "configDir": "."
+ },
+ "testFramework": "phpunit",
+ "tmpDir": ".infection",
+ "minMsi": 50,
+ "minCoveredMsi": 70
+}
diff --git a/locales/en_GB.json b/locales/en_GB.json
deleted file mode 100644
index 4f74cd8..0000000
--- a/locales/en_GB.json
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- "cmd": {
- "php": {
- "short": "Laravel/PHP development tools",
- "long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment",
- "label": {
- "php": "PHP:",
- "audit": "Audit:",
- "psalm": "Psalm:",
- "rector": "Rector:",
- "security": "Security:",
- "infection": "Infection:",
- "info": "Info:",
- "setup": "Setup:"
- },
- "error": {
- "not_php": "Not a PHP project (no composer.json found)",
- "fmt_failed": "Formatting failed",
- "fmt_issues": "Style issues found",
- "analysis_issues": "Analysis errors found",
- "audit_failed": "Audit failed",
- "vulns_found": "Vulnerabilities found",
- "psalm_not_installed": "Psalm not installed",
- "psalm_issues": "Psalm found type errors",
- "rector_not_installed": "Rector not installed",
- "rector_failed": "Rector failed",
- "infection_not_installed": "Infection not installed",
- "infection_failed": "Mutation testing failed",
- "security_failed": "Security check failed",
- "critical_high_issues": "Critical or high severity issues found"
- },
- "test": {
- "short": "Run PHPUnit/Pest tests",
- "long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage",
- "flag": {
- "parallel": "Run tests in parallel",
- "coverage": "Generate code coverage report",
- "filter": "Filter tests by name",
- "group": "Run only tests in this group"
- }
- },
- "fmt": {
- "short": "Format PHP code with Laravel Pint",
- "long": "Format PHP code using Laravel Pint code style fixer",
- "no_formatter": "No code formatter found (install laravel/pint)",
- "no_issues": "No style issues found",
- "formatting": "Formatting with {{.Formatter}}...",
- "flag": {
- "fix": "Fix style issues (default: check only)"
- }
- },
- "analyse": {
- "short": "Run PHPStan static analysis",
- "long": "Run PHPStan/Larastan for static code analysis",
- "no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)",
- "flag": {
- "level": "Analysis level (0-9, default: from config)",
- "memory": "Memory limit (e.g., 2G)"
- }
- },
- "audit": {
- "short": "Security audit for dependencies",
- "long": "Audit Composer and NPM dependencies for known vulnerabilities",
- "scanning": "Scanning dependencies for vulnerabilities...",
- "secure": "No vulnerabilities",
- "error": "Audit error",
- "vulnerabilities": "{{.Count}} vulnerabilities found",
- "found_vulns": "Found {{.Count}} vulnerabilities",
- "all_secure": "All dependencies secure",
- "completed_errors": "Audit completed with errors",
- "flag": {
- "fix": "Attempt to fix vulnerabilities"
- }
- },
- "psalm": {
- "short": "Run Psalm static analysis",
- "long": "Run Psalm for deep static analysis and type checking",
- "not_found": "Psalm not found",
- "install": "composer require --dev vimeo/psalm",
- "setup": "vendor/bin/psalm --init",
- "analysing": "Analysing with Psalm...",
- "analysing_fixing": "Analysing and fixing with Psalm...",
- "flag": {
- "level": "Analysis level (1-8)",
- "baseline": "Generate or update baseline",
- "show_info": "Show informational issues"
- }
- },
- "rector": {
- "short": "Automated code refactoring",
- "long": "Run Rector for automated code upgrades and refactoring",
- "not_found": "Rector not found",
- "install": "composer require --dev rector/rector",
- "setup": "vendor/bin/rector init",
- "analysing": "Analysing code for refactoring opportunities...",
- "refactoring": "Refactoring code...",
- "no_changes": "No refactoring changes needed",
- "changes_suggested": "Rector suggests changes (run with --fix to apply)",
- "flag": {
- "fix": "Apply refactoring changes",
- "diff": "Show diff of changes",
- "clear_cache": "Clear Rector cache before running"
- }
- },
- "infection": {
- "short": "Mutation testing for test quality",
- "long": "Run Infection mutation testing to measure test suite quality",
- "not_found": "Infection not found",
- "install": "composer require --dev infection/infection",
- "note": "This may take a while depending on test suite size",
- "complete": "Mutation testing complete",
- "flag": {
- "min_msi": "Minimum Mutation Score Indicator (0-100)",
- "min_covered_msi": "Minimum covered code MSI (0-100)",
- "threads": "Number of parallel threads",
- "filter": "Filter mutants by file path",
- "only_covered": "Only mutate covered code"
- }
- },
- "security": {
- "short": "Security vulnerability scanning",
- "long": "Run comprehensive security checks on PHP codebase",
- "checks_suffix": " CHECKS",
- "summary": "Security scan complete",
- "passed": "Passed:",
- "critical": "Critical:",
- "high": "High:",
- "medium": "Medium:",
- "low": "Low:",
- "flag": {
- "severity": "Minimum severity to report (low, medium, high, critical)",
- "sarif": "Output in SARIF format",
- "url": "Application URL for runtime checks"
- }
- },
- "qa": {
- "short": "Run full QA pipeline",
- "long": "Run comprehensive quality assurance: audit, format, analyse, test, and more",
- "flag": {
- "quick": "Run quick checks only (audit, fmt, stan)",
- "full": "Run all stages including slow checks",
- "fix": "Auto-fix issues where possible"
- }
- }
- }
- }
-}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1088009
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4102 @@
+{
+ "name": "core-php",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "autoprefixer": "^10.4.20",
+ "gray-matter": "^4.0.3",
+ "laravel-vite-plugin": "^1.2.0",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^4.0.0",
+ "vite": "^6.0.11",
+ "vitepress": "^1.6.4"
+ }
+ },
+ "node_modules/@algolia/abtesting": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.13.0.tgz",
+ "integrity": "sha512-Zrqam12iorp3FjiKMXSTpedGYznZ3hTEOAr2oCxI8tbF8bS1kQHClyDYNq/eV0ewMNLyFkgZVWjaS+8spsOYiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/autocomplete-core": {
+ "version": "1.17.7",
+ "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz",
+ "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/autocomplete-plugin-algolia-insights": "1.17.7",
+ "@algolia/autocomplete-shared": "1.17.7"
+ }
+ },
+ "node_modules/@algolia/autocomplete-plugin-algolia-insights": {
+ "version": "1.17.7",
+ "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz",
+ "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/autocomplete-shared": "1.17.7"
+ },
+ "peerDependencies": {
+ "search-insights": ">= 1 < 3"
+ }
+ },
+ "node_modules/@algolia/autocomplete-preset-algolia": {
+ "version": "1.17.7",
+ "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz",
+ "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/autocomplete-shared": "1.17.7"
+ },
+ "peerDependencies": {
+ "@algolia/client-search": ">= 4.9.1 < 6",
+ "algoliasearch": ">= 4.9.1 < 6"
+ }
+ },
+ "node_modules/@algolia/autocomplete-shared": {
+ "version": "1.17.7",
+ "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz",
+ "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@algolia/client-search": ">= 4.9.1 < 6",
+ "algoliasearch": ">= 4.9.1 < 6"
+ }
+ },
+ "node_modules/@algolia/client-abtesting": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.47.0.tgz",
+ "integrity": "sha512-aOpsdlgS9xTEvz47+nXmw8m0NtUiQbvGWNuSEb7fA46iPL5FxOmOUZkh8PREBJpZ0/H8fclSc7BMJCVr+Dn72w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-analytics": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.47.0.tgz",
+ "integrity": "sha512-EcF4w7IvIk1sowrO7Pdy4Ako7x/S8+nuCgdk6En+u5jsaNQM4rTT09zjBPA+WQphXkA2mLrsMwge96rf6i7Mow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-common": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.47.0.tgz",
+ "integrity": "sha512-Wzg5Me2FqgRDj0lFuPWFK05UOWccSMsIBL2YqmTmaOzxVlLZ+oUqvKbsUSOE5ud8Fo1JU7JyiLmEXBtgDKzTwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-insights": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.47.0.tgz",
+ "integrity": "sha512-Ci+cn/FDIsDxSKMRBEiyKrqybblbk8xugo6ujDN1GSTv9RIZxwxqZYuHfdLnLEwLlX7GB8pqVyqrUSlRnR+sJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-personalization": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.47.0.tgz",
+ "integrity": "sha512-gsLnHPZmWcX0T3IigkDL2imCNtsQ7dR5xfnwiFsb+uTHCuYQt+IwSNjsd8tok6HLGLzZrliSaXtB5mfGBtYZvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-query-suggestions": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.47.0.tgz",
+ "integrity": "sha512-PDOw0s8WSlR2fWFjPQldEpmm/gAoUgLigvC3k/jCSi/DzigdGX6RdC0Gh1RR1P8Cbk5KOWYDuL3TNzdYwkfDyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-search": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.47.0.tgz",
+ "integrity": "sha512-b5hlU69CuhnS2Rqgsz7uSW0t4VqrLMLTPbUpEl0QVz56rsSwr1Sugyogrjb493sWDA+XU1FU5m9eB8uH7MoI0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/ingestion": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.47.0.tgz",
+ "integrity": "sha512-WvwwXp5+LqIGISK3zHRApLT1xkuEk320/EGeD7uYy+K8WwDd5OjXnhjuXRhYr1685KnkvWkq1rQ/ihCJjOfHpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/monitoring": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.47.0.tgz",
+ "integrity": "sha512-j2EUFKAlzM0TE4GRfkDE3IDfkVeJdcbBANWzK16Tb3RHz87WuDfQ9oeEW6XiRE1/bEkq2xf4MvZesvSeQrZRDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/recommend": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.47.0.tgz",
+ "integrity": "sha512-+kTSE4aQ1ARj2feXyN+DMq0CIDHJwZw1kpxIunedkmpWUg8k3TzFwWsMCzJVkF2nu1UcFbl7xsIURz3Q3XwOXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/requester-browser-xhr": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.47.0.tgz",
+ "integrity": "sha512-Ja+zPoeSA2SDowPwCNRbm5Q2mzDvVV8oqxCQ4m6SNmbKmPlCfe30zPfrt9ho3kBHnsg37pGucwOedRIOIklCHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/requester-fetch": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.47.0.tgz",
+ "integrity": "sha512-N6nOvLbaR4Ge+oVm7T4W/ea1PqcSbsHR4O58FJ31XtZjFPtOyxmnhgCmGCzP9hsJI6+x0yxJjkW5BMK/XI8OvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/requester-node-http": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.47.0.tgz",
+ "integrity": "sha512-z1oyLq5/UVkohVXNDEY70mJbT/sv/t6HYtCvCwNrOri6pxBJDomP9R83KOlwcat+xqBQEdJHjbrPh36f1avmZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/client-common": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@docsearch/css": {
+ "version": "3.8.2",
+ "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz",
+ "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@docsearch/js": {
+ "version": "3.8.2",
+ "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz",
+ "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@docsearch/react": "3.8.2",
+ "preact": "^10.0.0"
+ }
+ },
+ "node_modules/@docsearch/react": {
+ "version": "3.8.2",
+ "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz",
+ "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/autocomplete-core": "1.17.7",
+ "@algolia/autocomplete-preset-algolia": "1.17.7",
+ "@docsearch/css": "3.8.2",
+ "algoliasearch": "^5.14.2"
+ },
+ "peerDependencies": {
+ "@types/react": ">= 16.8.0 < 19.0.0",
+ "react": ">= 16.8.0 < 19.0.0",
+ "react-dom": ">= 16.8.0 < 19.0.0",
+ "search-insights": ">= 1 < 3"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "search-insights": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@iconify-json/simple-icons": {
+ "version": "1.2.68",
+ "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.68.tgz",
+ "integrity": "sha512-bQPl1zuZlX6AnofreA1v7J+hoPncrFMppqGboe/SH54jZO37meiBUGBqNOxEpc0HKfZGxJaVVJwZd4gdMYu3hw==",
+ "dev": true,
+ "license": "CC0-1.0",
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz",
+ "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz",
+ "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz",
+ "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz",
+ "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz",
+ "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz",
+ "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz",
+ "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz",
+ "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz",
+ "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz",
+ "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz",
+ "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz",
+ "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz",
+ "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz",
+ "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz",
+ "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz",
+ "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz",
+ "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz",
+ "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz",
+ "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz",
+ "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz",
+ "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz",
+ "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz",
+ "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz",
+ "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz",
+ "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@shikijs/core": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz",
+ "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/engine-javascript": "2.5.0",
+ "@shikijs/engine-oniguruma": "2.5.0",
+ "@shikijs/types": "2.5.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4",
+ "hast-util-to-html": "^9.0.4"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz",
+ "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "2.5.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^3.1.0"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz",
+ "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "2.5.0",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz",
+ "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "2.5.0"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz",
+ "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "2.5.0"
+ }
+ },
+ "node_modules/@shikijs/transformers": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz",
+ "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "2.5.0",
+ "@shikijs/types": "2.5.0"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz",
+ "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
+ "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@vue/shared": "3.5.27",
+ "entities": "^7.0.0",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
+ "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.27",
+ "@vue/shared": "3.5.27"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
+ "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@vue/compiler-core": "3.5.27",
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/compiler-ssr": "3.5.27",
+ "@vue/shared": "3.5.27",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.6",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
+ "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/shared": "3.5.27"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
+ "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^7.7.9"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
+ "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-shared": "^7.7.9",
+ "birpc": "^2.3.0",
+ "hookable": "^5.5.3",
+ "mitt": "^3.0.1",
+ "perfect-debounce": "^1.0.0",
+ "speakingurl": "^14.0.1",
+ "superjson": "^2.2.2"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
+ "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rfdc": "^1.4.1"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
+ "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.27"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
+ "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.27",
+ "@vue/shared": "3.5.27"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
+ "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.27",
+ "@vue/runtime-core": "3.5.27",
+ "@vue/shared": "3.5.27",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
+ "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.27",
+ "@vue/shared": "3.5.27"
+ },
+ "peerDependencies": {
+ "vue": "3.5.27"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
+ "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vueuse/core": {
+ "version": "12.8.2",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
+ "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "12.8.2",
+ "@vueuse/shared": "12.8.2",
+ "vue": "^3.5.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/integrations": {
+ "version": "12.8.2",
+ "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz",
+ "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vueuse/core": "12.8.2",
+ "@vueuse/shared": "12.8.2",
+ "vue": "^3.5.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "async-validator": "^4",
+ "axios": "^1",
+ "change-case": "^5",
+ "drauu": "^0.4",
+ "focus-trap": "^7",
+ "fuse.js": "^7",
+ "idb-keyval": "^6",
+ "jwt-decode": "^4",
+ "nprogress": "^0.2",
+ "qrcode": "^1.5",
+ "sortablejs": "^1",
+ "universal-cookie": "^7"
+ },
+ "peerDependenciesMeta": {
+ "async-validator": {
+ "optional": true
+ },
+ "axios": {
+ "optional": true
+ },
+ "change-case": {
+ "optional": true
+ },
+ "drauu": {
+ "optional": true
+ },
+ "focus-trap": {
+ "optional": true
+ },
+ "fuse.js": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "jwt-decode": {
+ "optional": true
+ },
+ "nprogress": {
+ "optional": true
+ },
+ "qrcode": {
+ "optional": true
+ },
+ "sortablejs": {
+ "optional": true
+ },
+ "universal-cookie": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "12.8.2",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
+ "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "12.8.2",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
+ "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vue": "^3.5.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/algoliasearch": {
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz",
+ "integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/abtesting": "1.13.0",
+ "@algolia/client-abtesting": "5.47.0",
+ "@algolia/client-analytics": "5.47.0",
+ "@algolia/client-common": "5.47.0",
+ "@algolia/client-insights": "5.47.0",
+ "@algolia/client-personalization": "5.47.0",
+ "@algolia/client-query-suggestions": "5.47.0",
+ "@algolia/client-search": "5.47.0",
+ "@algolia/ingestion": "1.47.0",
+ "@algolia/monitoring": "1.47.0",
+ "@algolia/recommend": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.23",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
+ "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001760",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.16",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
+ "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001765",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
+ "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/copy-anything": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
+ "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-what": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.267",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex-xs": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
+ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/focus-trap": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
+ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tabbable": "^6.4.0"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/gray-matter": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
+ "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-yaml": "^3.13.1",
+ "kind-of": "^6.0.2",
+ "section-matter": "^1.0.0",
+ "strip-bom-string": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-what": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
+ "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/laravel-vite-plugin": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz",
+ "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "vite-plugin-full-reload": "^1.1.0"
+ },
+ "bin": {
+ "clean-orphaned-assets": "bin/clean.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mark.js": {
+ "version": "8.11.1",
+ "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
+ "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/minisearch": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
+ "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz",
+ "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex-xs": "^1.0.0",
+ "regex": "^6.0.1",
+ "regex-recursion": "^6.0.2"
+ }
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/preact": {
+ "version": "10.28.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
+ "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.55.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
+ "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.55.2",
+ "@rollup/rollup-android-arm64": "4.55.2",
+ "@rollup/rollup-darwin-arm64": "4.55.2",
+ "@rollup/rollup-darwin-x64": "4.55.2",
+ "@rollup/rollup-freebsd-arm64": "4.55.2",
+ "@rollup/rollup-freebsd-x64": "4.55.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.55.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.55.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.55.2",
+ "@rollup/rollup-linux-arm64-musl": "4.55.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.55.2",
+ "@rollup/rollup-linux-loong64-musl": "4.55.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.55.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.55.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.55.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.55.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.55.2",
+ "@rollup/rollup-linux-x64-gnu": "4.55.2",
+ "@rollup/rollup-linux-x64-musl": "4.55.2",
+ "@rollup/rollup-openbsd-x64": "4.55.2",
+ "@rollup/rollup-openharmony-arm64": "4.55.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.55.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.55.2",
+ "@rollup/rollup-win32-x64-gnu": "4.55.2",
+ "@rollup/rollup-win32-x64-msvc": "4.55.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/search-insights": {
+ "version": "2.17.3",
+ "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz",
+ "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/section-matter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
+ "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/shiki": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz",
+ "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "2.5.0",
+ "@shikijs/engine-javascript": "2.5.0",
+ "@shikijs/engine-oniguruma": "2.5.0",
+ "@shikijs/langs": "2.5.0",
+ "@shikijs/themes": "2.5.0",
+ "@shikijs/types": "2.5.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/speakingurl": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
+ "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-bom-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+ "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/superjson": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
+ "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "copy-anything": "^4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-full-reload": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
+ "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "picomatch": "^2.3.1"
+ }
+ },
+ "node_modules/vite-plugin-full-reload/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitepress": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
+ "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@docsearch/css": "3.8.2",
+ "@docsearch/js": "3.8.2",
+ "@iconify-json/simple-icons": "^1.2.21",
+ "@shikijs/core": "^2.1.0",
+ "@shikijs/transformers": "^2.1.0",
+ "@shikijs/types": "^2.1.0",
+ "@types/markdown-it": "^14.1.2",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "@vue/devtools-api": "^7.7.0",
+ "@vue/shared": "^3.5.13",
+ "@vueuse/core": "^12.4.0",
+ "@vueuse/integrations": "^12.4.0",
+ "focus-trap": "^7.6.4",
+ "mark.js": "8.11.1",
+ "minisearch": "^7.1.1",
+ "shiki": "^2.1.0",
+ "vite": "^5.4.14",
+ "vue": "^3.5.13"
+ },
+ "bin": {
+ "vitepress": "bin/vitepress.js"
+ },
+ "peerDependencies": {
+ "markdown-it-mathjax3": "^4",
+ "postcss": "^8"
+ },
+ "peerDependenciesMeta": {
+ "markdown-it-mathjax3": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitepress/node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/vitepress/node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
+ "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/compiler-sfc": "3.5.27",
+ "@vue/runtime-dom": "3.5.27",
+ "@vue/server-renderer": "3.5.27",
+ "@vue/shared": "3.5.27"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..769d1a9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "docs:dev": "vitepress dev docs",
+ "docs:build": "vitepress build docs",
+ "docs:preview": "vitepress preview docs"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "autoprefixer": "^10.4.20",
+ "gray-matter": "^4.0.3",
+ "laravel-vite-plugin": "^1.2.0",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^4.0.0",
+ "vite": "^6.0.11",
+ "vitepress": "^1.6.4"
+ }
+}
diff --git a/packages.go b/packages.go
deleted file mode 100644
index 03645d6..0000000
--- a/packages.go
+++ /dev/null
@@ -1,308 +0,0 @@
-package php
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
-
- "forge.lthn.ai/core/go/pkg/cli"
-)
-
-// LinkedPackage represents a linked local package.
-type LinkedPackage struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Version string `json:"version"`
-}
-
-// composerRepository represents a composer repository entry.
-type composerRepository struct {
- Type string `json:"type"`
- URL string `json:"url,omitempty"`
- Options map[string]any `json:"options,omitempty"`
-}
-
-// readComposerJSON reads and parses composer.json from the given directory.
-func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
- m := getMedium()
- composerPath := filepath.Join(dir, "composer.json")
- content, err := m.Read(composerPath)
- if err != nil {
- return nil, cli.WrapVerb(err, "read", "composer.json")
- }
-
- var raw map[string]json.RawMessage
- if err := json.Unmarshal([]byte(content), &raw); err != nil {
- return nil, cli.WrapVerb(err, "parse", "composer.json")
- }
-
- return raw, nil
-}
-
-// writeComposerJSON writes the composer.json to the given directory.
-func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
- m := getMedium()
- composerPath := filepath.Join(dir, "composer.json")
-
- data, err := json.MarshalIndent(raw, "", " ")
- if err != nil {
- return cli.WrapVerb(err, "marshal", "composer.json")
- }
-
- // Add trailing newline
- content := string(data) + "\n"
-
- if err := m.Write(composerPath, content); err != nil {
- return cli.WrapVerb(err, "write", "composer.json")
- }
-
- return nil
-}
-
-// getRepositories extracts repositories from raw composer.json.
-func getRepositories(raw map[string]json.RawMessage) ([]composerRepository, error) {
- reposRaw, ok := raw["repositories"]
- if !ok {
- return []composerRepository{}, nil
- }
-
- var repos []composerRepository
- if err := json.Unmarshal(reposRaw, &repos); err != nil {
- return nil, cli.WrapVerb(err, "parse", "repositories")
- }
-
- return repos, nil
-}
-
-// setRepositories sets repositories in raw composer.json.
-func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) error {
- if len(repos) == 0 {
- delete(raw, "repositories")
- return nil
- }
-
- reposData, err := json.Marshal(repos)
- if err != nil {
- return cli.WrapVerb(err, "marshal", "repositories")
- }
-
- raw["repositories"] = reposData
- return nil
-}
-
-// getPackageInfo reads package name and version from a composer.json in the given path.
-func getPackageInfo(packagePath string) (name, version string, err error) {
- m := getMedium()
- composerPath := filepath.Join(packagePath, "composer.json")
- content, err := m.Read(composerPath)
- if err != nil {
- return "", "", cli.WrapVerb(err, "read", "package composer.json")
- }
-
- var pkg struct {
- Name string `json:"name"`
- Version string `json:"version"`
- }
-
- if err := json.Unmarshal([]byte(content), &pkg); err != nil {
- return "", "", cli.WrapVerb(err, "parse", "package composer.json")
- }
-
- if pkg.Name == "" {
- return "", "", cli.Err("package name not found in composer.json")
- }
-
- return pkg.Name, pkg.Version, nil
-}
-
-// LinkPackages adds path repositories to composer.json for local package development.
-func LinkPackages(dir string, packages []string) error {
- if !IsPHPProject(dir) {
- return cli.Err("not a PHP project (missing composer.json)")
- }
-
- raw, err := readComposerJSON(dir)
- if err != nil {
- return err
- }
-
- repos, err := getRepositories(raw)
- if err != nil {
- return err
- }
-
- for _, packagePath := range packages {
- // Resolve absolute path
- absPath, err := filepath.Abs(packagePath)
- if err != nil {
- return cli.Err("failed to resolve path %s: %w", packagePath, err)
- }
-
- // Verify the path exists and has a composer.json
- if !IsPHPProject(absPath) {
- return cli.Err("not a PHP package (missing composer.json): %s", absPath)
- }
-
- // Get package name for validation
- pkgName, _, err := getPackageInfo(absPath)
- if err != nil {
- return cli.Err("failed to get package info from %s: %w", absPath, err)
- }
-
- // Check if already linked
- alreadyLinked := false
- for _, repo := range repos {
- if repo.Type == "path" && repo.URL == absPath {
- alreadyLinked = true
- break
- }
- }
-
- if alreadyLinked {
- continue
- }
-
- // Add path repository
- repos = append(repos, composerRepository{
- Type: "path",
- URL: absPath,
- Options: map[string]any{
- "symlink": true,
- },
- })
-
- cli.Print("Linked: %s -> %s\n", pkgName, absPath)
- }
-
- if err := setRepositories(raw, repos); err != nil {
- return err
- }
-
- return writeComposerJSON(dir, raw)
-}
-
-// UnlinkPackages removes path repositories from composer.json.
-func UnlinkPackages(dir string, packages []string) error {
- if !IsPHPProject(dir) {
- return cli.Err("not a PHP project (missing composer.json)")
- }
-
- raw, err := readComposerJSON(dir)
- if err != nil {
- return err
- }
-
- repos, err := getRepositories(raw)
- if err != nil {
- return err
- }
-
- // Build set of packages to unlink
- toUnlink := make(map[string]bool)
- for _, pkg := range packages {
- toUnlink[pkg] = true
- }
-
- // Filter out unlinked packages
- filtered := make([]composerRepository, 0, len(repos))
- for _, repo := range repos {
- if repo.Type != "path" {
- filtered = append(filtered, repo)
- continue
- }
-
- // Check if this repo should be unlinked
- shouldUnlink := false
-
- // Try to get package name from the path
- if IsPHPProject(repo.URL) {
- pkgName, _, err := getPackageInfo(repo.URL)
- if err == nil && toUnlink[pkgName] {
- shouldUnlink = true
- cli.Print("Unlinked: %s\n", pkgName)
- }
- }
-
- // Also check if path matches any of the provided names
- for pkg := range toUnlink {
- if repo.URL == pkg || filepath.Base(repo.URL) == pkg {
- shouldUnlink = true
- cli.Print("Unlinked: %s\n", repo.URL)
- break
- }
- }
-
- if !shouldUnlink {
- filtered = append(filtered, repo)
- }
- }
-
- if err := setRepositories(raw, filtered); err != nil {
- return err
- }
-
- return writeComposerJSON(dir, raw)
-}
-
-// UpdatePackages runs composer update for specific packages.
-func UpdatePackages(dir string, packages []string) error {
- if !IsPHPProject(dir) {
- return cli.Err("not a PHP project (missing composer.json)")
- }
-
- args := []string{"update"}
- args = append(args, packages...)
-
- cmd := exec.Command("composer", args...)
- cmd.Dir = dir
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- return cmd.Run()
-}
-
-// ListLinkedPackages returns all path repositories from composer.json.
-func ListLinkedPackages(dir string) ([]LinkedPackage, error) {
- if !IsPHPProject(dir) {
- return nil, cli.Err("not a PHP project (missing composer.json)")
- }
-
- raw, err := readComposerJSON(dir)
- if err != nil {
- return nil, err
- }
-
- repos, err := getRepositories(raw)
- if err != nil {
- return nil, err
- }
-
- linked := make([]LinkedPackage, 0)
- for _, repo := range repos {
- if repo.Type != "path" {
- continue
- }
-
- pkg := LinkedPackage{
- Path: repo.URL,
- }
-
- // Try to get package info
- if IsPHPProject(repo.URL) {
- name, version, err := getPackageInfo(repo.URL)
- if err == nil {
- pkg.Name = name
- pkg.Version = version
- }
- }
-
- if pkg.Name == "" {
- pkg.Name = filepath.Base(repo.URL)
- }
-
- linked = append(linked, pkg)
- }
-
- return linked, nil
-}
diff --git a/packages_test.go b/packages_test.go
deleted file mode 100644
index a340a9b..0000000
--- a/packages_test.go
+++ /dev/null
@@ -1,543 +0,0 @@
-package php
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestReadComposerJSON_Good(t *testing.T) {
- t.Run("reads valid composer.json", func(t *testing.T) {
- dir := t.TempDir()
- composerJSON := `{
- "name": "test/project",
- "require": {
- "php": "^8.2"
- }
- }`
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- raw, err := readComposerJSON(dir)
- assert.NoError(t, err)
- assert.NotNil(t, raw)
- assert.Contains(t, string(raw["name"]), "test/project")
- })
-
- t.Run("preserves all fields", func(t *testing.T) {
- dir := t.TempDir()
- composerJSON := `{
- "name": "test/project",
- "description": "Test project",
- "require": {"php": "^8.2"},
- "autoload": {"psr-4": {"App\\": "src/"}}
- }`
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- raw, err := readComposerJSON(dir)
- assert.NoError(t, err)
- assert.Contains(t, string(raw["autoload"]), "psr-4")
- })
-}
-
-func TestReadComposerJSON_Bad(t *testing.T) {
- t.Run("missing composer.json", func(t *testing.T) {
- dir := t.TempDir()
- _, err := readComposerJSON(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to read composer.json")
- })
-
- t.Run("invalid JSON", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
- require.NoError(t, err)
-
- _, err = readComposerJSON(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to parse composer.json")
- })
-}
-
-func TestWriteComposerJSON_Good(t *testing.T) {
- t.Run("writes valid composer.json", func(t *testing.T) {
- dir := t.TempDir()
- raw := make(map[string]json.RawMessage)
- raw["name"] = json.RawMessage(`"test/project"`)
-
- err := writeComposerJSON(dir, raw)
- assert.NoError(t, err)
-
- // Verify file was written
- content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
- assert.NoError(t, err)
- assert.Contains(t, string(content), "test/project")
- // Verify trailing newline
- assert.True(t, content[len(content)-1] == '\n')
- })
-
- t.Run("pretty prints with indentation", func(t *testing.T) {
- dir := t.TempDir()
- raw := make(map[string]json.RawMessage)
- raw["name"] = json.RawMessage(`"test/project"`)
- raw["require"] = json.RawMessage(`{"php":"^8.2"}`)
-
- err := writeComposerJSON(dir, raw)
- assert.NoError(t, err)
-
- content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
- assert.NoError(t, err)
- // Should be indented
- assert.Contains(t, string(content), " ")
- })
-}
-
-func TestWriteComposerJSON_Bad(t *testing.T) {
- t.Run("fails for non-existent directory", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["name"] = json.RawMessage(`"test/project"`)
-
- err := writeComposerJSON("/non/existent/path", raw)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to write composer.json")
- })
-}
-func TestGetRepositories_Good(t *testing.T) {
- t.Run("returns empty slice when no repositories", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["name"] = json.RawMessage(`"test/project"`)
-
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Empty(t, repos)
- })
-
- t.Run("parses existing repositories", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["name"] = json.RawMessage(`"test/project"`)
- raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`)
-
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 1)
- assert.Equal(t, "path", repos[0].Type)
- assert.Equal(t, "/path/to/package", repos[0].URL)
- })
-
- t.Run("parses repositories with options", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`)
-
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 1)
- assert.NotNil(t, repos[0].Options)
- assert.Equal(t, true, repos[0].Options["symlink"])
- })
-}
-
-func TestGetRepositories_Bad(t *testing.T) {
- t.Run("fails for invalid repositories JSON", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["repositories"] = json.RawMessage(`not valid json`)
-
- _, err := getRepositories(raw)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to parse repositories")
- })
-}
-
-func TestSetRepositories_Good(t *testing.T) {
- t.Run("sets repositories", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- repos := []composerRepository{
- {Type: "path", URL: "/path/to/package"},
- }
-
- err := setRepositories(raw, repos)
- assert.NoError(t, err)
- assert.Contains(t, string(raw["repositories"]), "/path/to/package")
- })
-
- t.Run("removes repositories key when empty", func(t *testing.T) {
- raw := make(map[string]json.RawMessage)
- raw["repositories"] = json.RawMessage(`[{"type":"path"}]`)
-
- err := setRepositories(raw, []composerRepository{})
- assert.NoError(t, err)
- _, exists := raw["repositories"]
- assert.False(t, exists)
- })
-}
-
-func TestGetPackageInfo_Good(t *testing.T) {
- t.Run("extracts package name and version", func(t *testing.T) {
- dir := t.TempDir()
- composerJSON := `{
- "name": "vendor/package",
- "version": "1.0.0"
- }`
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- name, version, err := getPackageInfo(dir)
- assert.NoError(t, err)
- assert.Equal(t, "vendor/package", name)
- assert.Equal(t, "1.0.0", version)
- })
-
- t.Run("works without version", func(t *testing.T) {
- dir := t.TempDir()
- composerJSON := `{
- "name": "vendor/package"
- }`
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- name, version, err := getPackageInfo(dir)
- assert.NoError(t, err)
- assert.Equal(t, "vendor/package", name)
- assert.Equal(t, "", version)
- })
-}
-
-func TestGetPackageInfo_Bad(t *testing.T) {
- t.Run("missing composer.json", func(t *testing.T) {
- dir := t.TempDir()
- _, _, err := getPackageInfo(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to read package composer.json")
- })
-
- t.Run("invalid JSON", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
- require.NoError(t, err)
-
- _, _, err = getPackageInfo(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to parse package composer.json")
- })
-
- t.Run("missing name", func(t *testing.T) {
- dir := t.TempDir()
- composerJSON := `{"version": "1.0.0"}`
- err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- _, _, err = getPackageInfo(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "package name not found")
- })
-}
-
-func TestLinkPackages_Good(t *testing.T) {
- t.Run("links a package", func(t *testing.T) {
- // Create project directory
- projectDir := t.TempDir()
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
- require.NoError(t, err)
-
- // Create package directory
- packageDir := t.TempDir()
- err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
- require.NoError(t, err)
-
- err = LinkPackages(projectDir, []string{packageDir})
- assert.NoError(t, err)
-
- // Verify repository was added
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 1)
- assert.Equal(t, "path", repos[0].Type)
- })
-
- t.Run("skips already linked package", func(t *testing.T) {
- // Create project with existing repository
- projectDir := t.TempDir()
- packageDir := t.TempDir()
-
- err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
- require.NoError(t, err)
-
- absPackagePath, _ := filepath.Abs(packageDir)
- composerJSON := `{
- "name": "test/project",
- "repositories": [{"type":"path","url":"` + absPackagePath + `"}]
- }`
- err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- // Link again - should not add duplicate
- err = LinkPackages(projectDir, []string{packageDir})
- assert.NoError(t, err)
-
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 1) // Still only one
- })
-
- t.Run("links multiple packages", func(t *testing.T) {
- projectDir := t.TempDir()
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
- require.NoError(t, err)
-
- pkg1Dir := t.TempDir()
- err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644)
- require.NoError(t, err)
-
- pkg2Dir := t.TempDir()
- err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644)
- require.NoError(t, err)
-
- err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir})
- assert.NoError(t, err)
-
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 2)
- })
-}
-
-func TestLinkPackages_Bad(t *testing.T) {
- t.Run("fails for non-PHP project", func(t *testing.T) {
- dir := t.TempDir()
- err := LinkPackages(dir, []string{"/path/to/package"})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a PHP project")
- })
-
- t.Run("fails for non-PHP package", func(t *testing.T) {
- projectDir := t.TempDir()
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
- require.NoError(t, err)
-
- packageDir := t.TempDir()
- // No composer.json in package
-
- err = LinkPackages(projectDir, []string{packageDir})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a PHP package")
- })
-}
-
-func TestUnlinkPackages_Good(t *testing.T) {
- t.Run("unlinks package by name", func(t *testing.T) {
- projectDir := t.TempDir()
- packageDir := t.TempDir()
-
- err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
- require.NoError(t, err)
-
- absPackagePath, _ := filepath.Abs(packageDir)
- composerJSON := `{
- "name": "test/project",
- "repositories": [{"type":"path","url":"` + absPackagePath + `"}]
- }`
- err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- err = UnlinkPackages(projectDir, []string{"vendor/package"})
- assert.NoError(t, err)
-
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 0)
- })
-
- t.Run("unlinks package by path", func(t *testing.T) {
- projectDir := t.TempDir()
- packageDir := t.TempDir()
-
- absPackagePath, _ := filepath.Abs(packageDir)
- composerJSON := `{
- "name": "test/project",
- "repositories": [{"type":"path","url":"` + absPackagePath + `"}]
- }`
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- err = UnlinkPackages(projectDir, []string{absPackagePath})
- assert.NoError(t, err)
-
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 0)
- })
-
- t.Run("keeps non-path repositories", func(t *testing.T) {
- projectDir := t.TempDir()
- composerJSON := `{
- "name": "test/project",
- "repositories": [
- {"type":"vcs","url":"https://github.com/vendor/package"},
- {"type":"path","url":"/local/path"}
- ]
- }`
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- err = UnlinkPackages(projectDir, []string{"/local/path"})
- assert.NoError(t, err)
-
- raw, err := readComposerJSON(projectDir)
- assert.NoError(t, err)
- repos, err := getRepositories(raw)
- assert.NoError(t, err)
- assert.Len(t, repos, 1)
- assert.Equal(t, "vcs", repos[0].Type)
- })
-}
-
-func TestUnlinkPackages_Bad(t *testing.T) {
- t.Run("fails for non-PHP project", func(t *testing.T) {
- dir := t.TempDir()
- err := UnlinkPackages(dir, []string{"vendor/package"})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a PHP project")
- })
-}
-
-func TestListLinkedPackages_Good(t *testing.T) {
- t.Run("lists linked packages", func(t *testing.T) {
- projectDir := t.TempDir()
- packageDir := t.TempDir()
-
- err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644)
- require.NoError(t, err)
-
- absPackagePath, _ := filepath.Abs(packageDir)
- composerJSON := `{
- "name": "test/project",
- "repositories": [{"type":"path","url":"` + absPackagePath + `"}]
- }`
- err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- linked, err := ListLinkedPackages(projectDir)
- assert.NoError(t, err)
- assert.Len(t, linked, 1)
- assert.Equal(t, "vendor/package", linked[0].Name)
- assert.Equal(t, "1.0.0", linked[0].Version)
- assert.Equal(t, absPackagePath, linked[0].Path)
- })
-
- t.Run("returns empty list when no linked packages", func(t *testing.T) {
- projectDir := t.TempDir()
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
- require.NoError(t, err)
-
- linked, err := ListLinkedPackages(projectDir)
- assert.NoError(t, err)
- assert.Empty(t, linked)
- })
-
- t.Run("uses basename when package info unavailable", func(t *testing.T) {
- projectDir := t.TempDir()
- composerJSON := `{
- "name": "test/project",
- "repositories": [{"type":"path","url":"/nonexistent/package-name"}]
- }`
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- linked, err := ListLinkedPackages(projectDir)
- assert.NoError(t, err)
- assert.Len(t, linked, 1)
- assert.Equal(t, "package-name", linked[0].Name)
- })
-
- t.Run("ignores non-path repositories", func(t *testing.T) {
- projectDir := t.TempDir()
- composerJSON := `{
- "name": "test/project",
- "repositories": [
- {"type":"vcs","url":"https://github.com/vendor/package"}
- ]
- }`
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- linked, err := ListLinkedPackages(projectDir)
- assert.NoError(t, err)
- assert.Empty(t, linked)
- })
-}
-
-func TestListLinkedPackages_Bad(t *testing.T) {
- t.Run("fails for non-PHP project", func(t *testing.T) {
- dir := t.TempDir()
- _, err := ListLinkedPackages(dir)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a PHP project")
- })
-}
-
-func TestUpdatePackages_Bad(t *testing.T) {
- t.Run("fails for non-PHP project", func(t *testing.T) {
- dir := t.TempDir()
- err := UpdatePackages(dir, []string{"vendor/package"})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a PHP project")
- })
-}
-
-func TestUpdatePackages_Good(t *testing.T) {
- t.Skip("requires Composer installed")
-
- t.Run("runs composer update", func(t *testing.T) {
- projectDir := t.TempDir()
- err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
- require.NoError(t, err)
-
- _ = UpdatePackages(projectDir, []string{"vendor/package"})
- // This will fail because composer update needs real dependencies
- // but it validates the command runs
- })
-}
-
-func TestLinkedPackage_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- pkg := LinkedPackage{
- Name: "vendor/package",
- Path: "/path/to/package",
- Version: "1.0.0",
- }
-
- assert.Equal(t, "vendor/package", pkg.Name)
- assert.Equal(t, "/path/to/package", pkg.Path)
- assert.Equal(t, "1.0.0", pkg.Version)
- })
-}
-
-func TestComposerRepository_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- repo := composerRepository{
- Type: "path",
- URL: "/path/to/package",
- Options: map[string]any{
- "symlink": true,
- },
- }
-
- assert.Equal(t, "path", repo.Type)
- assert.Equal(t, "/path/to/package", repo.URL)
- assert.Equal(t, true, repo.Options["symlink"])
- })
-}
diff --git a/php-commands.yaml b/php-commands.yaml
new file mode 100644
index 0000000..1d8eb78
--- /dev/null
+++ b/php-commands.yaml
@@ -0,0 +1,325 @@
+# PHP Command Specifications for core CLI
+# Add these commands to the core binary
+#
+# Existing: test, fmt, analyse
+# New: psalm, audit, security, qa, rector, infection
+
+commands:
+ # ==========================================================================
+ # NEW: core php psalm
+ # ==========================================================================
+ psalm:
+ description: Run Psalm static analysis
+ long_description: |
+ Run Psalm deep static analysis with Laravel plugin support.
+
+ Psalm provides deeper type inference than PHPStan and catches
+ different classes of bugs. Both should be run for best coverage.
+
+ Examples:
+ core php psalm # Run analysis
+ core php psalm --fix # Auto-fix issues where possible
+ core php psalm --level 3 # Run at specific level (1-8)
+ core php psalm --baseline # Generate baseline file
+
+ flags:
+ - name: fix
+ type: bool
+ description: Auto-fix issues where possible
+ maps_to: "--alter"
+
+ - name: level
+ type: int
+ default: 8
+ description: Error level (1=strictest, 8=most lenient)
+ maps_to: "--error-level"
+
+ - name: baseline
+ type: bool
+ description: Generate/update baseline file
+ maps_to: "--set-baseline=psalm-baseline.xml"
+
+ - name: show-info
+ type: bool
+ description: Show info-level issues
+ maps_to: "--show-info=true"
+
+ detection:
+ config_file: psalm.xml
+ binary: ./vendor/bin/psalm
+
+ command_template: |
+ {{.Binary}} {{if .Level}}--error-level={{.Level}}{{end}} {{.ExtraFlags}} --no-progress
+
+ # ==========================================================================
+ # NEW: core php audit
+ # ==========================================================================
+ audit:
+ description: Security audit for dependencies
+ long_description: |
+ Check PHP and JavaScript dependencies for known vulnerabilities.
+
+ Runs composer audit and npm audit (if package.json exists).
+
+ Examples:
+ core php audit # Check all dependencies
+ core php audit --json # Output as JSON
+ core php audit --fix # Auto-fix where possible (npm only)
+
+ flags:
+ - name: json
+ type: bool
+ description: Output in JSON format
+
+ - name: fix
+ type: bool
+ description: Auto-fix vulnerabilities (npm only)
+
+ steps:
+ - name: Composer Audit
+ command: composer audit {{if .JSON}}--format=json{{end}}
+ always_run: true
+ fail_on_error: true
+
+ - name: NPM Audit
+ command: npm audit {{if .JSON}}--json{{end}} {{if .Fix}}--fix{{end}}
+ when_file_exists: package.json
+ fail_on_error: true
+
+ # ==========================================================================
+ # NEW: core php security
+ # ==========================================================================
+ security:
+ description: Security vulnerability scanning
+ long_description: |
+ Scan for security vulnerabilities using security-checks.yaml rules.
+
+ Checks environment config, file permissions, code patterns,
+ and runs security-focused static analysis.
+
+ Examples:
+ core php security # Run all checks
+ core php security --severity=high # Only high+ severity
+ core php security --json # JSON output
+ core php security --sarif # SARIF format for GitHub
+
+ flags:
+ - name: severity
+ type: string
+ default: "medium"
+ description: Minimum severity (critical, high, medium, low)
+
+ - name: json
+ type: bool
+ description: Output in JSON format
+
+ - name: sarif
+ type: bool
+ description: Output in SARIF format (for GitHub Security)
+
+ - name: url
+ type: string
+ description: URL to check HTTP headers (optional)
+
+ config_file: security-checks.yaml
+
+ implementation_notes: |
+ Parse security-checks.yaml and run checks by category:
+ 1. env_checks: Parse .env file
+ 2. filesystem_checks: Use os.Stat, filepath.Glob
+ 3. config_checks: Regex on PHP files
+ 4. pattern_checks: Regex on source files
+ 5. tool_checks: Shell out to composer audit, phpstan
+ 6. header_checks: HTTP GET if --url provided
+
+ # ==========================================================================
+ # NEW: core php qa
+ # ==========================================================================
+ qa:
+ description: Run full QA pipeline
+ long_description: |
+ Run the complete quality assurance pipeline defined in qa.yaml.
+
+ Stages:
+ quick: Security audit, code style, PHPStan (< 30s)
+ standard: Psalm, tests (< 2 min)
+ full: Rector dry-run, mutation testing (slow)
+
+ Examples:
+ core php qa # Run quick + standard stages
+ core php qa --quick # Only quick checks
+ core php qa --full # All stages including slow ones
+ core php qa --fix # Auto-fix where possible
+
+ flags:
+ - name: quick
+ type: bool
+ description: Only run quick checks
+
+ - name: full
+ type: bool
+ description: Run all stages including slow checks
+
+ - name: fix
+ type: bool
+ description: Auto-fix issues where possible
+
+ - name: json
+ type: bool
+ description: Output results as JSON
+
+ config_file: qa.yaml
+
+ default_stages: [quick, standard]
+
+ implementation_notes: |
+ Parse qa.yaml and run stages in order:
+ 1. Load stage definitions from qa.yaml
+ 2. For each stage in selected stages:
+ - Run each check command
+ - If --fix and fix_command exists, run that instead
+ - Collect results
+ 3. Output summary with pass/fail per stage
+ 4. Exit with appropriate code per qa.yaml exit_codes
+
+ # ==========================================================================
+ # NEW: core php rector
+ # ==========================================================================
+ rector:
+ description: Automated code refactoring
+ long_description: |
+ Run Rector for automated code improvements and PHP upgrades.
+
+ Rector can automatically upgrade PHP syntax, improve code quality,
+ and apply framework-specific refactorings.
+
+ Examples:
+ core php rector # Dry-run (show changes)
+ core php rector --fix # Apply changes
+ core php rector --diff # Show detailed diff
+
+ flags:
+ - name: fix
+ type: bool
+ description: Apply changes (default is dry-run)
+
+ - name: diff
+ type: bool
+ description: Show detailed diff of changes
+ maps_to: "--output-format diff"
+
+ - name: clear-cache
+ type: bool
+ description: Clear Rector cache before running
+ maps_to: "--clear-cache"
+
+ detection:
+ config_file: rector.php
+ binary: ./vendor/bin/rector
+
+ command_template: |
+ {{.Binary}} process {{if not .Fix}}--dry-run{{end}} {{.ExtraFlags}}
+
+ # ==========================================================================
+ # NEW: core php infection
+ # ==========================================================================
+ infection:
+ description: Mutation testing for test quality
+ long_description: |
+ Run Infection mutation testing to measure test suite quality.
+
+ Mutation testing modifies your code and checks if tests catch
+ the changes. High mutation score = high quality tests.
+
+ Warning: This can be slow on large codebases.
+
+ Examples:
+ core php infection # Run mutation testing
+ core php infection --min-msi=70 # Require 70% mutation score
+ core php infection --filter=User # Only test User* files
+
+ flags:
+ - name: min-msi
+ type: int
+ default: 50
+ description: Minimum mutation score indicator (0-100)
+ maps_to: "--min-msi"
+
+ - name: min-covered-msi
+ type: int
+ default: 70
+ description: Minimum covered mutation score (0-100)
+ maps_to: "--min-covered-msi"
+
+ - name: threads
+ type: int
+ default: 4
+ description: Number of parallel threads
+ maps_to: "--threads"
+
+ - name: filter
+ type: string
+ description: Filter files by pattern
+ maps_to: "--filter"
+
+ - name: only-covered
+ type: bool
+ description: Only mutate covered code
+ maps_to: "--only-covered"
+
+ detection:
+ config_file: infection.json5
+ binary: ./vendor/bin/infection
+
+ command_template: |
+ {{.Binary}} --min-msi={{.MinMSI}} --min-covered-msi={{.MinCoveredMSI}} --threads={{.Threads}} {{.ExtraFlags}}
+
+# ==========================================================================
+# UPDATED: Enhance existing commands
+# ==========================================================================
+enhancements:
+ analyse:
+ add_flags:
+ - name: psalm
+ type: bool
+ description: Also run Psalm analysis
+ note: "Run both PHPStan and Psalm for comprehensive coverage"
+
+ note: |
+ Consider adding --psalm flag to run both tools:
+ core php analyse --psalm # Runs PHPStan then Psalm
+
+ test:
+ add_flags:
+ - name: mutation
+ type: bool
+ description: Also run mutation testing
+ note: "Run Infection after tests pass"
+
+ note: |
+ Consider adding --mutation flag:
+ core php test --mutation # Runs tests then Infection
+
+# ==========================================================================
+# COMMAND GROUPS (for help display)
+# ==========================================================================
+groups:
+ development:
+ description: Development tools
+ commands: [dev, logs, stop, status, shell]
+
+ quality:
+ description: Code quality and testing
+ commands: [test, fmt, analyse, psalm, qa]
+
+ security:
+ description: Security and auditing
+ commands: [audit, security]
+
+ refactoring:
+ description: Code improvement
+ commands: [rector, infection]
+
+ deployment:
+ description: Build and deploy
+ commands: [build, serve, deploy, deploy:status, deploy:rollback, deploy:list]
diff --git a/php.go b/php.go
deleted file mode 100644
index 96393eb..0000000
--- a/php.go
+++ /dev/null
@@ -1,397 +0,0 @@
-package php
-
-import (
- "context"
- "io"
- "os"
- "sync"
- "time"
-
- "forge.lthn.ai/core/go/pkg/cli"
-)
-
-// Options configures the development server.
-type Options struct {
- // Dir is the Laravel project directory.
- Dir string
-
- // Services specifies which services to start.
- // If empty, services are auto-detected.
- Services []DetectedService
-
- // NoVite disables the Vite dev server.
- NoVite bool
-
- // NoHorizon disables Laravel Horizon.
- NoHorizon bool
-
- // NoReverb disables Laravel Reverb.
- NoReverb bool
-
- // NoRedis disables the Redis server.
- NoRedis bool
-
- // HTTPS enables HTTPS with mkcert certificates.
- HTTPS bool
-
- // Domain is the domain for SSL certificates.
- // Defaults to APP_URL from .env or "localhost".
- Domain string
-
- // Ports for each service
- FrankenPHPPort int
- HTTPSPort int
- VitePort int
- ReverbPort int
- RedisPort int
-}
-
-// DevServer manages all development services.
-type DevServer struct {
- opts Options
- services []Service
- ctx context.Context
- cancel context.CancelFunc
- mu sync.RWMutex
- running bool
-}
-
-// NewDevServer creates a new development server manager.
-func NewDevServer(opts Options) *DevServer {
- return &DevServer{
- opts: opts,
- services: make([]Service, 0),
- }
-}
-
-// Start starts all detected/configured services.
-func (d *DevServer) Start(ctx context.Context, opts Options) error {
- d.mu.Lock()
- defer d.mu.Unlock()
-
- if d.running {
- return cli.Err("dev server is already running")
- }
-
- // Merge options
- if opts.Dir != "" {
- d.opts.Dir = opts.Dir
- }
- if d.opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- d.opts.Dir = cwd
- }
-
- // Verify this is a Laravel project
- if !IsLaravelProject(d.opts.Dir) {
- return cli.Err("not a Laravel project: %s", d.opts.Dir)
- }
-
- // Create cancellable context
- d.ctx, d.cancel = context.WithCancel(ctx)
-
- // Detect or use provided services
- services := opts.Services
- if len(services) == 0 {
- services = DetectServices(d.opts.Dir)
- }
-
- // Filter out disabled services
- services = d.filterServices(services, opts)
-
- // Setup SSL if HTTPS is enabled
- var certFile, keyFile string
- if opts.HTTPS {
- domain := opts.Domain
- if domain == "" {
- // Try to get domain from APP_URL
- appURL := GetLaravelAppURL(d.opts.Dir)
- if appURL != "" {
- domain = ExtractDomainFromURL(appURL)
- }
- }
- if domain == "" {
- domain = "localhost"
- }
-
- var err error
- certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{})
- if err != nil {
- return cli.WrapVerb(err, "setup", "SSL")
- }
- }
-
- // Create services
- d.services = make([]Service, 0)
-
- for _, svc := range services {
- var service Service
-
- switch svc {
- case ServiceFrankenPHP:
- port := opts.FrankenPHPPort
- if port == 0 {
- port = 8000
- }
- httpsPort := opts.HTTPSPort
- if httpsPort == 0 {
- httpsPort = 443
- }
- service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{
- Port: port,
- HTTPSPort: httpsPort,
- HTTPS: opts.HTTPS,
- CertFile: certFile,
- KeyFile: keyFile,
- })
-
- case ServiceVite:
- port := opts.VitePort
- if port == 0 {
- port = 5173
- }
- service = NewViteService(d.opts.Dir, ViteOptions{
- Port: port,
- })
-
- case ServiceHorizon:
- service = NewHorizonService(d.opts.Dir)
-
- case ServiceReverb:
- port := opts.ReverbPort
- if port == 0 {
- port = 8080
- }
- service = NewReverbService(d.opts.Dir, ReverbOptions{
- Port: port,
- })
-
- case ServiceRedis:
- port := opts.RedisPort
- if port == 0 {
- port = 6379
- }
- service = NewRedisService(d.opts.Dir, RedisOptions{
- Port: port,
- })
- }
-
- if service != nil {
- d.services = append(d.services, service)
- }
- }
-
- // Start all services
- var startErrors []error
- for _, svc := range d.services {
- if err := svc.Start(d.ctx); err != nil {
- startErrors = append(startErrors, cli.Err("%s: %v", svc.Name(), err))
- }
- }
-
- if len(startErrors) > 0 {
- // Stop any services that did start
- for _, svc := range d.services {
- _ = svc.Stop()
- }
- return cli.Err("failed to start services: %v", startErrors)
- }
-
- d.running = true
- return nil
-}
-
-// filterServices removes disabled services from the list.
-func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService {
- filtered := make([]DetectedService, 0)
-
- for _, svc := range services {
- switch svc {
- case ServiceVite:
- if !opts.NoVite {
- filtered = append(filtered, svc)
- }
- case ServiceHorizon:
- if !opts.NoHorizon {
- filtered = append(filtered, svc)
- }
- case ServiceReverb:
- if !opts.NoReverb {
- filtered = append(filtered, svc)
- }
- case ServiceRedis:
- if !opts.NoRedis {
- filtered = append(filtered, svc)
- }
- default:
- filtered = append(filtered, svc)
- }
- }
-
- return filtered
-}
-
-// Stop stops all services gracefully.
-func (d *DevServer) Stop() error {
- d.mu.Lock()
- defer d.mu.Unlock()
-
- if !d.running {
- return nil
- }
-
- // Cancel context first
- if d.cancel != nil {
- d.cancel()
- }
-
- // Stop all services in reverse order
- var stopErrors []error
- for i := len(d.services) - 1; i >= 0; i-- {
- svc := d.services[i]
- if err := svc.Stop(); err != nil {
- stopErrors = append(stopErrors, cli.Err("%s: %v", svc.Name(), err))
- }
- }
-
- d.running = false
-
- if len(stopErrors) > 0 {
- return cli.Err("errors stopping services: %v", stopErrors)
- }
-
- return nil
-}
-
-// Logs returns a reader for the specified service's logs.
-// If service is empty, returns unified logs from all services.
-func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) {
- d.mu.RLock()
- defer d.mu.RUnlock()
-
- if service == "" {
- // Return unified logs
- return d.unifiedLogs(follow)
- }
-
- // Find specific service
- for _, svc := range d.services {
- if svc.Name() == service {
- return svc.Logs(follow)
- }
- }
-
- return nil, cli.Err("service not found: %s", service)
-}
-
-// unifiedLogs creates a reader that combines logs from all services.
-func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) {
- readers := make([]io.ReadCloser, 0)
-
- for _, svc := range d.services {
- reader, err := svc.Logs(follow)
- if err != nil {
- // Close any readers we already opened
- for _, r := range readers {
- _ = r.Close()
- }
- return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err)
- }
- readers = append(readers, reader)
- }
-
- return newMultiServiceReader(d.services, readers, follow), nil
-}
-
-// Status returns the status of all services.
-func (d *DevServer) Status() []ServiceStatus {
- d.mu.RLock()
- defer d.mu.RUnlock()
-
- statuses := make([]ServiceStatus, 0, len(d.services))
- for _, svc := range d.services {
- statuses = append(statuses, svc.Status())
- }
-
- return statuses
-}
-
-// IsRunning returns true if the dev server is running.
-func (d *DevServer) IsRunning() bool {
- d.mu.RLock()
- defer d.mu.RUnlock()
- return d.running
-}
-
-// Services returns the list of managed services.
-func (d *DevServer) Services() []Service {
- d.mu.RLock()
- defer d.mu.RUnlock()
- return d.services
-}
-
-// multiServiceReader combines multiple service log readers.
-type multiServiceReader struct {
- services []Service
- readers []io.ReadCloser
- follow bool
- closed bool
- mu sync.RWMutex
-}
-
-func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader {
- return &multiServiceReader{
- services: services,
- readers: readers,
- follow: follow,
- }
-}
-
-func (m *multiServiceReader) Read(p []byte) (n int, err error) {
- m.mu.RLock()
- if m.closed {
- m.mu.RUnlock()
- return 0, io.EOF
- }
- m.mu.RUnlock()
-
- // Round-robin read from all readers
- for i, reader := range m.readers {
- buf := make([]byte, len(p))
- n, err := reader.Read(buf)
- if n > 0 {
- // Prefix with service name
- prefix := cli.Sprintf("[%s] ", m.services[i].Name())
- copy(p, prefix)
- copy(p[len(prefix):], buf[:n])
- return n + len(prefix), nil
- }
- if err != nil && err != io.EOF {
- return 0, err
- }
- }
-
- if m.follow {
- time.Sleep(100 * time.Millisecond)
- return 0, nil
- }
-
- return 0, io.EOF
-}
-
-func (m *multiServiceReader) Close() error {
- m.mu.Lock()
- m.closed = true
- m.mu.Unlock()
-
- var closeErr error
- for _, reader := range m.readers {
- if err := reader.Close(); err != nil && closeErr == nil {
- closeErr = err
- }
- }
- return closeErr
-}
diff --git a/php_test.go b/php_test.go
deleted file mode 100644
index e295d73..0000000
--- a/php_test.go
+++ /dev/null
@@ -1,644 +0,0 @@
-package php
-
-import (
- "context"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestNewDevServer_Good(t *testing.T) {
- t.Run("creates dev server with default options", func(t *testing.T) {
- opts := Options{}
- server := NewDevServer(opts)
-
- assert.NotNil(t, server)
- assert.Empty(t, server.services)
- assert.False(t, server.running)
- })
-
- t.Run("creates dev server with custom options", func(t *testing.T) {
- opts := Options{
- Dir: "/tmp/test",
- NoVite: true,
- NoHorizon: true,
- FrankenPHPPort: 9000,
- }
- server := NewDevServer(opts)
-
- assert.NotNil(t, server)
- assert.Equal(t, "/tmp/test", server.opts.Dir)
- assert.True(t, server.opts.NoVite)
- })
-}
-
-func TestDevServer_IsRunning_Good(t *testing.T) {
- t.Run("returns false when not running", func(t *testing.T) {
- server := NewDevServer(Options{})
- assert.False(t, server.IsRunning())
- })
-}
-
-func TestDevServer_Status_Good(t *testing.T) {
- t.Run("returns empty status when no services", func(t *testing.T) {
- server := NewDevServer(Options{})
- statuses := server.Status()
- assert.Empty(t, statuses)
- })
-}
-
-func TestDevServer_Services_Good(t *testing.T) {
- t.Run("returns empty services list initially", func(t *testing.T) {
- server := NewDevServer(Options{})
- services := server.Services()
- assert.Empty(t, services)
- })
-}
-
-func TestDevServer_Stop_Good(t *testing.T) {
- t.Run("returns nil when not running", func(t *testing.T) {
- server := NewDevServer(Options{})
- err := server.Stop()
- assert.NoError(t, err)
- })
-}
-
-func TestDevServer_Start_Bad(t *testing.T) {
- t.Run("fails when already running", func(t *testing.T) {
- server := NewDevServer(Options{})
- server.running = true
-
- err := server.Start(context.Background(), Options{})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "already running")
- })
-
- t.Run("fails for non-Laravel project", func(t *testing.T) {
- dir := t.TempDir()
- server := NewDevServer(Options{Dir: dir})
-
- err := server.Start(context.Background(), Options{Dir: dir})
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "not a Laravel project")
- })
-}
-
-func TestDevServer_Logs_Bad(t *testing.T) {
- t.Run("fails for non-existent service", func(t *testing.T) {
- server := NewDevServer(Options{})
-
- _, err := server.Logs("nonexistent", false)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "service not found")
- })
-}
-
-func TestDevServer_filterServices_Good(t *testing.T) {
- tests := []struct {
- name string
- services []DetectedService
- opts Options
- expected []DetectedService
- }{
- {
- name: "no filtering with default options",
- services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
- opts: Options{},
- expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
- },
- {
- name: "filters Vite when NoVite is true",
- services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
- opts: Options{NoVite: true},
- expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon},
- },
- {
- name: "filters Horizon when NoHorizon is true",
- services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
- opts: Options{NoHorizon: true},
- expected: []DetectedService{ServiceFrankenPHP, ServiceVite},
- },
- {
- name: "filters Reverb when NoReverb is true",
- services: []DetectedService{ServiceFrankenPHP, ServiceReverb},
- opts: Options{NoReverb: true},
- expected: []DetectedService{ServiceFrankenPHP},
- },
- {
- name: "filters Redis when NoRedis is true",
- services: []DetectedService{ServiceFrankenPHP, ServiceRedis},
- opts: Options{NoRedis: true},
- expected: []DetectedService{ServiceFrankenPHP},
- },
- {
- name: "filters multiple services",
- services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis},
- opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true},
- expected: []DetectedService{ServiceFrankenPHP},
- },
- {
- name: "keeps unknown services",
- services: []DetectedService{ServiceFrankenPHP},
- opts: Options{NoVite: true},
- expected: []DetectedService{ServiceFrankenPHP},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- server := NewDevServer(Options{})
- result := server.filterServices(tt.services, tt.opts)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
-func TestMultiServiceReader_Good(t *testing.T) {
- t.Run("closes all readers on Close", func(t *testing.T) {
- // Create mock readers using files
- dir := t.TempDir()
- file1, err := os.CreateTemp(dir, "log1-*.log")
- require.NoError(t, err)
- _, _ = file1.WriteString("test1")
- _, _ = file1.Seek(0, 0)
-
- file2, err := os.CreateTemp(dir, "log2-*.log")
- require.NoError(t, err)
- _, _ = file2.WriteString("test2")
- _, _ = file2.Seek(0, 0)
-
- // Create mock services
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1"}},
- &ViteService{baseService: baseService{name: "svc2"}},
- }
- readers := []io.ReadCloser{file1, file2}
-
- reader := newMultiServiceReader(services, readers, false)
- assert.NotNil(t, reader)
-
- err = reader.Close()
- assert.NoError(t, err)
- assert.True(t, reader.closed)
- })
-
- t.Run("returns EOF when closed", func(t *testing.T) {
- reader := &multiServiceReader{closed: true}
- buf := make([]byte, 10)
- n, err := reader.Read(buf)
- assert.Equal(t, 0, n)
- assert.Equal(t, io.EOF, err)
- })
-}
-
-func TestMultiServiceReader_Read_Good(t *testing.T) {
- t.Run("reads from readers with service prefix", func(t *testing.T) {
- dir := t.TempDir()
- file1, err := os.CreateTemp(dir, "log-*.log")
- require.NoError(t, err)
- _, _ = file1.WriteString("log content")
- _, _ = file1.Seek(0, 0)
-
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "TestService"}},
- }
- readers := []io.ReadCloser{file1}
-
- reader := newMultiServiceReader(services, readers, false)
- buf := make([]byte, 100)
- n, err := reader.Read(buf)
-
- assert.NoError(t, err)
- assert.Greater(t, n, 0)
- result := string(buf[:n])
- assert.Contains(t, result, "[TestService]")
- })
-
- t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) {
- dir := t.TempDir()
- file1, err := os.CreateTemp(dir, "log-*.log")
- require.NoError(t, err)
- _ = file1.Close() // Empty file
-
- file1, err = os.Open(file1.Name())
- require.NoError(t, err)
-
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "TestService"}},
- }
- readers := []io.ReadCloser{file1}
-
- reader := newMultiServiceReader(services, readers, false)
- buf := make([]byte, 100)
- n, err := reader.Read(buf)
-
- assert.Equal(t, 0, n)
- assert.Equal(t, io.EOF, err)
- })
-}
-
-func TestOptions_Good(t *testing.T) {
- t.Run("all fields are accessible", func(t *testing.T) {
- opts := Options{
- Dir: "/test",
- Services: []DetectedService{ServiceFrankenPHP},
- NoVite: true,
- NoHorizon: true,
- NoReverb: true,
- NoRedis: true,
- HTTPS: true,
- Domain: "test.local",
- FrankenPHPPort: 8000,
- HTTPSPort: 443,
- VitePort: 5173,
- ReverbPort: 8080,
- RedisPort: 6379,
- }
-
- assert.Equal(t, "/test", opts.Dir)
- assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services)
- assert.True(t, opts.NoVite)
- assert.True(t, opts.NoHorizon)
- assert.True(t, opts.NoReverb)
- assert.True(t, opts.NoRedis)
- assert.True(t, opts.HTTPS)
- assert.Equal(t, "test.local", opts.Domain)
- assert.Equal(t, 8000, opts.FrankenPHPPort)
- assert.Equal(t, 443, opts.HTTPSPort)
- assert.Equal(t, 5173, opts.VitePort)
- assert.Equal(t, 8080, opts.ReverbPort)
- assert.Equal(t, 6379, opts.RedisPort)
- })
-}
-
-func TestDevServer_StartStop_Integration(t *testing.T) {
- t.Skip("requires PHP/FrankenPHP installed")
-
- dir := t.TempDir()
- setupLaravelProject(t, dir)
-
- server := NewDevServer(Options{Dir: dir})
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- err := server.Start(ctx, Options{Dir: dir})
- require.NoError(t, err)
- assert.True(t, server.IsRunning())
-
- err = server.Stop()
- require.NoError(t, err)
- assert.False(t, server.IsRunning())
-}
-
-// setupLaravelProject creates a minimal Laravel project structure for testing.
-func setupLaravelProject(t *testing.T, dir string) {
- t.Helper()
-
- // Create artisan file
- err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
- require.NoError(t, err)
-
- // Create composer.json with Laravel
- composerJSON := `{
- "name": "test/laravel-project",
- "require": {
- "php": "^8.2",
- "laravel/framework": "^11.0",
- "laravel/octane": "^2.0"
- }
- }`
- err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-}
-
-func TestDevServer_UnifiedLogs_Bad(t *testing.T) {
- t.Run("returns error when service logs fail", func(t *testing.T) {
- server := NewDevServer(Options{})
-
- // Create a mock service that will fail to provide logs
- mockService := &FrankenPHPService{
- baseService: baseService{
- name: "FailingService",
- logPath: "", // No log path set will cause error
- },
- }
- server.services = []Service{mockService}
-
- _, err := server.Logs("", false)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "failed to get logs")
- })
-}
-
-func TestDevServer_Logs_Good(t *testing.T) {
- t.Run("finds specific service logs", func(t *testing.T) {
- dir := t.TempDir()
- logFile := filepath.Join(dir, "test.log")
- err := os.WriteFile(logFile, []byte("test log content"), 0644)
- require.NoError(t, err)
-
- server := NewDevServer(Options{})
- mockService := &FrankenPHPService{
- baseService: baseService{
- name: "TestService",
- logPath: logFile,
- },
- }
- server.services = []Service{mockService}
-
- reader, err := server.Logs("TestService", false)
- assert.NoError(t, err)
- assert.NotNil(t, reader)
- _ = reader.Close()
- })
-}
-
-func TestDevServer_MergeOptions_Good(t *testing.T) {
- t.Run("start merges options correctly", func(t *testing.T) {
- dir := t.TempDir()
- server := NewDevServer(Options{Dir: "/original"})
-
- // Setup a minimal non-Laravel project to trigger an error
- // but still test the options merge happens first
- err := server.Start(context.Background(), Options{Dir: dir})
- assert.Error(t, err) // Will fail because not Laravel project
- // But the directory should have been merged
- assert.Equal(t, dir, server.opts.Dir)
- })
-}
-
-func TestDetectedService_Constants(t *testing.T) {
- t.Run("all service constants are defined", func(t *testing.T) {
- assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP)
- assert.Equal(t, DetectedService("vite"), ServiceVite)
- assert.Equal(t, DetectedService("horizon"), ServiceHorizon)
- assert.Equal(t, DetectedService("reverb"), ServiceReverb)
- assert.Equal(t, DetectedService("redis"), ServiceRedis)
- })
-}
-
-func TestDevServer_HTTPSSetup(t *testing.T) {
- t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create Laravel project
- err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
- require.NoError(t, err)
-
- composerJSON := `{
- "require": {
- "laravel/framework": "^11.0",
- "laravel/octane": "^2.0"
- }
- }`
- err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
- require.NoError(t, err)
-
- // Create .env with APP_URL
- envContent := "APP_URL=https://myapp.test"
- err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
- require.NoError(t, err)
-
- // Verify we can extract the domain
- url := GetLaravelAppURL(dir)
- domain := ExtractDomainFromURL(url)
- assert.Equal(t, "myapp.test", domain)
- })
-}
-
-func TestDevServer_PortDefaults(t *testing.T) {
- t.Run("uses default ports when not specified", func(t *testing.T) {
- // This tests the logic in Start() for default port assignment
- // We verify the constants/defaults by checking what would be created
-
- // FrankenPHP default port is 8000
- svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{})
- assert.Equal(t, 8000, svc.port)
-
- // Vite default port is 5173
- vite := NewViteService("/tmp", ViteOptions{})
- assert.Equal(t, 5173, vite.port)
-
- // Reverb default port is 8080
- reverb := NewReverbService("/tmp", ReverbOptions{})
- assert.Equal(t, 8080, reverb.port)
-
- // Redis default port is 6379
- redis := NewRedisService("/tmp", RedisOptions{})
- assert.Equal(t, 6379, redis.port)
- })
-}
-
-func TestDevServer_ServiceCreation(t *testing.T) {
- t.Run("creates correct services based on detected services", func(t *testing.T) {
- // Test that the switch statement in Start() creates the right service types
- services := []DetectedService{
- ServiceFrankenPHP,
- ServiceVite,
- ServiceHorizon,
- ServiceReverb,
- ServiceRedis,
- }
-
- // Verify each service type string
- expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"}
- for i, svc := range services {
- assert.Equal(t, expected[i], string(svc))
- }
- })
-}
-
-func TestMultiServiceReader_CloseError(t *testing.T) {
- t.Run("returns first close error", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create a real file that we can close
- file1, err := os.CreateTemp(dir, "log-*.log")
- require.NoError(t, err)
- file1Name := file1.Name()
- _ = file1.Close()
-
- // Reopen for reading
- file1, err = os.Open(file1Name)
- require.NoError(t, err)
-
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1"}},
- }
- readers := []io.ReadCloser{file1}
-
- reader := newMultiServiceReader(services, readers, false)
- err = reader.Close()
- assert.NoError(t, err)
-
- // Second close should still work (files already closed)
- // The closed flag prevents double-processing
- assert.True(t, reader.closed)
- })
-}
-
-func TestMultiServiceReader_FollowMode(t *testing.T) {
- t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) {
- dir := t.TempDir()
- file1, err := os.CreateTemp(dir, "log-*.log")
- require.NoError(t, err)
- file1Name := file1.Name()
- _ = file1.Close()
-
- // Reopen for reading (empty file)
- file1, err = os.Open(file1Name)
- require.NoError(t, err)
-
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1"}},
- }
- readers := []io.ReadCloser{file1}
-
- reader := newMultiServiceReader(services, readers, true) // follow=true
-
- // Use a channel to timeout the read since follow mode waits
- done := make(chan bool)
- go func() {
- buf := make([]byte, 100)
- n, err := reader.Read(buf)
- // In follow mode, should return 0 bytes and nil error (waiting for more data)
- assert.Equal(t, 0, n)
- assert.NoError(t, err)
- done <- true
- }()
-
- select {
- case <-done:
- // Good, read completed
- case <-time.After(500 * time.Millisecond):
- // Also acceptable - follow mode is waiting
- }
-
- _ = reader.Close()
- })
-}
-
-func TestGetLaravelAppURL_Bad(t *testing.T) {
- t.Run("no .env file", func(t *testing.T) {
- dir := t.TempDir()
- assert.Equal(t, "", GetLaravelAppURL(dir))
- })
-
- t.Run("no APP_URL in .env", func(t *testing.T) {
- dir := t.TempDir()
- envContent := "APP_NAME=Test\nAPP_ENV=local"
- err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
- require.NoError(t, err)
-
- assert.Equal(t, "", GetLaravelAppURL(dir))
- })
-}
-
-func TestExtractDomainFromURL_Edge(t *testing.T) {
- tests := []struct {
- name string
- url string
- expected string
- }{
- {"empty string", "", ""},
- {"just domain", "example.com", "example.com"},
- {"http only", "http://", ""},
- {"https only", "https://", ""},
- {"domain with trailing slash", "https://example.com/", "example.com"},
- {"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Strip protocol
- result := ExtractDomainFromURL(tt.url)
- if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") {
- assert.Equal(t, tt.expected, result)
- }
- })
- }
-}
-
-func TestDevServer_StatusWithServices(t *testing.T) {
- t.Run("returns statuses for all services", func(t *testing.T) {
- server := NewDevServer(Options{})
-
- // Add mock services
- server.services = []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}},
- &ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}},
- }
-
- statuses := server.Status()
- assert.Len(t, statuses, 2)
- assert.Equal(t, "svc1", statuses[0].Name)
- assert.True(t, statuses[0].Running)
- assert.Equal(t, "svc2", statuses[1].Name)
- assert.False(t, statuses[1].Running)
- })
-}
-
-func TestDevServer_ServicesReturnsAll(t *testing.T) {
- t.Run("returns all services", func(t *testing.T) {
- server := NewDevServer(Options{})
-
- // Add mock services
- server.services = []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1"}},
- &ViteService{baseService: baseService{name: "svc2"}},
- &HorizonService{baseService: baseService{name: "svc3"}},
- }
-
- services := server.Services()
- assert.Len(t, services, 3)
- })
-}
-
-func TestDevServer_StopWithCancel(t *testing.T) {
- t.Run("calls cancel when running", func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- server := NewDevServer(Options{})
- server.running = true
- server.cancel = cancel
- server.ctx = ctx
-
- // Add a mock service that won't error
- server.services = []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1", running: false}},
- }
-
- err := server.Stop()
- assert.NoError(t, err)
- assert.False(t, server.running)
- })
-}
-
-func TestMultiServiceReader_CloseWithErrors(t *testing.T) {
- t.Run("handles multiple close errors", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create files
- file1, err := os.CreateTemp(dir, "log1-*.log")
- require.NoError(t, err)
- file2, err := os.CreateTemp(dir, "log2-*.log")
- require.NoError(t, err)
-
- services := []Service{
- &FrankenPHPService{baseService: baseService{name: "svc1"}},
- &ViteService{baseService: baseService{name: "svc2"}},
- }
- readers := []io.ReadCloser{file1, file2}
-
- reader := newMultiServiceReader(services, readers, false)
-
- // Close successfully
- err = reader.Close()
- assert.NoError(t, err)
- })
-}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..2ab5346
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,23 @@
+parameters:
+ paths:
+ - src
+ level: 1
+ ignoreErrors:
+ - '#Unsafe usage of new static#'
+ - '#env\(\).*outside of the config directory#'
+ - identifier: larastan.noEnvCallsOutsideOfConfig
+ - identifier: trait.unused
+ - identifier: class.notFound
+ - identifier: function.deprecated
+ - identifier: method.notFound
+ excludePaths:
+ - src/Core/Activity
+ - src/Core/Config/Tests
+ - src/Core/Input/Tests
+ - src/Core/Tests
+ - src/Core/Bouncer/Tests
+ - src/Core/Bouncer/Gate/Tests
+ - src/Core/Service/Tests
+ - src/Core/Front/Tests
+ - src/Mod/Trees
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..e6d3db7
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,46 @@
+
+
+
+
+ tests/Feature
+
+
+ tests/Unit
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..1366687
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/qa.yaml b/qa.yaml
new file mode 100644
index 0000000..86a463e
--- /dev/null
+++ b/qa.yaml
@@ -0,0 +1,107 @@
+# PHP Quality Assurance Pipeline
+# This file defines the QA process for `core php qa` command
+#
+# Usage: core php qa [--fix] [--full]
+# --fix Apply automatic fixes where possible
+# --full Run full suite including slow checks (mutation testing)
+
+name: PHP Quality Assurance
+version: 1.0.0
+
+# Tool versions and config files
+tools:
+ pint:
+ config: pint.json
+ description: Code style (PSR-12 + Laravel conventions)
+
+ phpstan:
+ config: phpstan.neon
+ level: 1
+ description: Static analysis (type checking)
+
+ psalm:
+ config: psalm.xml
+ level: 8
+ description: Static analysis (deeper type inference)
+
+ infection:
+ config: infection.json5
+ description: Mutation testing (test quality)
+
+ rector:
+ config: rector.php
+ description: Automated refactoring and upgrades
+
+# QA Pipeline stages
+stages:
+ # Stage 1: Quick checks (< 30 seconds)
+ quick:
+ - name: Security Audit
+ command: composer audit
+ description: Check dependencies for known vulnerabilities
+ fix: false
+
+ - name: Code Style
+ command: ./vendor/bin/pint --test
+ fix_command: ./vendor/bin/pint
+ description: Check PSR-12 and Laravel code style
+
+ - name: PHPStan
+ command: ./vendor/bin/phpstan analyse --no-progress
+ description: Static analysis level 1
+ fix: false
+
+ # Stage 2: Standard checks (< 2 minutes)
+ standard:
+ - name: Psalm
+ command: ./vendor/bin/psalm --no-progress
+ description: Deep static analysis
+ fix: false
+
+ - name: Tests
+ command: ./vendor/bin/phpunit --testdox
+ description: Run test suite
+ fix: false
+
+ # Stage 3: Full checks (can be slow)
+ full:
+ - name: Rector (dry-run)
+ command: ./vendor/bin/rector process --dry-run
+ fix_command: ./vendor/bin/rector process
+ description: Check for automated improvements
+
+ - name: Mutation Testing
+ command: ./vendor/bin/infection --min-msi=50 --min-covered-msi=70 --threads=4
+ description: Test suite quality via mutation testing
+ fix: false
+ slow: true
+
+# Exit codes
+exit_codes:
+ 0: All checks passed
+ 1: Code style issues (fixable)
+ 2: Static analysis errors
+ 3: Test failures
+ 4: Security vulnerabilities
+ 5: Mutation score too low
+
+# Recommended CI configuration
+ci:
+ # Run on every push
+ push:
+ - quick
+ - standard
+
+ # Run on PRs to main
+ pull_request:
+ - quick
+ - standard
+ - full
+
+# Thresholds
+thresholds:
+ phpstan_level: 1
+ psalm_level: 8
+ test_coverage: 70
+ mutation_msi: 50
+ mutation_covered_msi: 70
diff --git a/quality.go b/quality.go
deleted file mode 100644
index a7f9638..0000000
--- a/quality.go
+++ /dev/null
@@ -1,994 +0,0 @@
-package php
-
-import (
- "context"
- "encoding/json"
- goio "io"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "forge.lthn.ai/core/go/pkg/cli"
- "forge.lthn.ai/core/go/pkg/i18n"
-)
-
-// FormatOptions configures PHP code formatting.
-type FormatOptions struct {
- // Dir is the project directory (defaults to current working directory).
- Dir string
-
- // Fix automatically fixes formatting issues.
- Fix bool
-
- // Diff shows a diff of changes instead of modifying files.
- Diff bool
-
- // JSON outputs results in JSON format.
- JSON bool
-
- // Paths limits formatting to specific paths.
- Paths []string
-
- // Output is the writer for output (defaults to os.Stdout).
- Output goio.Writer
-}
-
-// AnalyseOptions configures PHP static analysis.
-type AnalyseOptions struct {
- // Dir is the project directory (defaults to current working directory).
- Dir string
-
- // Level is the PHPStan analysis level (0-9).
- Level int
-
- // Paths limits analysis to specific paths.
- Paths []string
-
- // Memory is the memory limit for analysis (e.g., "2G").
- Memory string
-
- // JSON outputs results in JSON format.
- JSON bool
-
- // SARIF outputs results in SARIF format for GitHub Security tab.
- SARIF bool
-
- // Output is the writer for output (defaults to os.Stdout).
- Output goio.Writer
-}
-
-// FormatterType represents the detected formatter.
-type FormatterType string
-
-// Formatter type constants.
-const (
- // FormatterPint indicates Laravel Pint code formatter.
- FormatterPint FormatterType = "pint"
-)
-
-// AnalyserType represents the detected static analyser.
-type AnalyserType string
-
-// Static analyser type constants.
-const (
- // AnalyserPHPStan indicates standard PHPStan analyser.
- AnalyserPHPStan AnalyserType = "phpstan"
- // AnalyserLarastan indicates Laravel-specific Larastan analyser.
- AnalyserLarastan AnalyserType = "larastan"
-)
-
-// DetectFormatter detects which formatter is available in the project.
-func DetectFormatter(dir string) (FormatterType, bool) {
- m := getMedium()
-
- // Check for Pint config
- pintConfig := filepath.Join(dir, "pint.json")
- if m.Exists(pintConfig) {
- return FormatterPint, true
- }
-
- // Check for vendor binary
- pintBin := filepath.Join(dir, "vendor", "bin", "pint")
- if m.Exists(pintBin) {
- return FormatterPint, true
- }
-
- return "", false
-}
-
-// DetectAnalyser detects which static analyser is available in the project.
-func DetectAnalyser(dir string) (AnalyserType, bool) {
- m := getMedium()
-
- // Check for PHPStan config
- phpstanConfig := filepath.Join(dir, "phpstan.neon")
- phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
-
- hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig)
-
- // Check for vendor binary
- phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
- hasBin := m.Exists(phpstanBin)
-
- if hasConfig || hasBin {
- // Check if it's Larastan (Laravel-specific PHPStan)
- larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
- if m.Exists(larastanPath) {
- return AnalyserLarastan, true
- }
- // Also check nunomaduro/larastan
- larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
- if m.Exists(larastanPath2) {
- return AnalyserLarastan, true
- }
- return AnalyserPHPStan, true
- }
-
- return "", false
-}
-
-// Format runs Laravel Pint to format PHP code.
-func Format(ctx context.Context, opts FormatOptions) error {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- // Check if formatter is available
- formatter, found := DetectFormatter(opts.Dir)
- if !found {
- return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)")
- }
-
- var cmdName string
- var args []string
-
- switch formatter {
- case FormatterPint:
- cmdName, args = buildPintCommand(opts)
- }
-
- cmd := exec.CommandContext(ctx, cmdName, args...)
- cmd.Dir = opts.Dir
- cmd.Stdout = opts.Output
- cmd.Stderr = opts.Output
-
- return cmd.Run()
-}
-
-// Analyse runs PHPStan or Larastan for static analysis.
-func Analyse(ctx context.Context, opts AnalyseOptions) error {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- // Check if analyser is available
- analyser, found := DetectAnalyser(opts.Dir)
- if !found {
- return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
- }
-
- var cmdName string
- var args []string
-
- switch analyser {
- case AnalyserPHPStan, AnalyserLarastan:
- cmdName, args = buildPHPStanCommand(opts)
- }
-
- cmd := exec.CommandContext(ctx, cmdName, args...)
- cmd.Dir = opts.Dir
- cmd.Stdout = opts.Output
- cmd.Stderr = opts.Output
-
- return cmd.Run()
-}
-
-// buildPintCommand builds the command for running Laravel Pint.
-func buildPintCommand(opts FormatOptions) (string, []string) {
- m := getMedium()
-
- // Check for vendor binary first
- vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
- cmdName := "pint"
- if m.Exists(vendorBin) {
- cmdName = vendorBin
- }
-
- var args []string
-
- if !opts.Fix {
- args = append(args, "--test")
- }
-
- if opts.Diff {
- args = append(args, "--diff")
- }
-
- if opts.JSON {
- args = append(args, "--format=json")
- }
-
- // Add specific paths if provided
- args = append(args, opts.Paths...)
-
- return cmdName, args
-}
-
-// buildPHPStanCommand builds the command for running PHPStan.
-func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
- m := getMedium()
-
- // Check for vendor binary first
- vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
- cmdName := "phpstan"
- if m.Exists(vendorBin) {
- cmdName = vendorBin
- }
-
- args := []string{"analyse"}
-
- if opts.Level > 0 {
- args = append(args, "--level", cli.Sprintf("%d", opts.Level))
- }
-
- if opts.Memory != "" {
- args = append(args, "--memory-limit", opts.Memory)
- }
-
- // Output format - SARIF takes precedence over JSON
- if opts.SARIF {
- args = append(args, "--error-format=sarif")
- } else if opts.JSON {
- args = append(args, "--error-format=json")
- }
-
- // Add specific paths if provided
- args = append(args, opts.Paths...)
-
- return cmdName, args
-}
-
-// =============================================================================
-// Psalm Static Analysis
-// =============================================================================
-
-// PsalmOptions configures Psalm static analysis.
-type PsalmOptions struct {
- Dir string
- Level int // Error level (1=strictest, 8=most lenient)
- Fix bool // Auto-fix issues where possible
- Baseline bool // Generate/update baseline file
- ShowInfo bool // Show info-level issues
- JSON bool // Output in JSON format
- SARIF bool // Output in SARIF format for GitHub Security tab
- Output goio.Writer
-}
-
-// PsalmType represents the detected Psalm configuration.
-type PsalmType string
-
-// Psalm configuration type constants.
-const (
- // PsalmStandard indicates standard Psalm configuration.
- PsalmStandard PsalmType = "psalm"
-)
-
-// DetectPsalm checks if Psalm is available in the project.
-func DetectPsalm(dir string) (PsalmType, bool) {
- m := getMedium()
-
- // Check for psalm.xml config
- psalmConfig := filepath.Join(dir, "psalm.xml")
- psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
-
- hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig)
-
- // Check for vendor binary
- psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
- if m.Exists(psalmBin) {
- return PsalmStandard, true
- }
-
- if hasConfig {
- return PsalmStandard, true
- }
-
- return "", false
-}
-
-// RunPsalm runs Psalm static analysis.
-func RunPsalm(ctx context.Context, opts PsalmOptions) error {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- m := getMedium()
-
- // Build command
- vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
- cmdName := "psalm"
- if m.Exists(vendorBin) {
- cmdName = vendorBin
- }
-
- args := []string{"--no-progress"}
-
- if opts.Level > 0 && opts.Level <= 8 {
- args = append(args, cli.Sprintf("--error-level=%d", opts.Level))
- }
-
- if opts.Fix {
- args = append(args, "--alter", "--issues=all")
- }
-
- if opts.Baseline {
- args = append(args, "--set-baseline=psalm-baseline.xml")
- }
-
- if opts.ShowInfo {
- args = append(args, "--show-info=true")
- }
-
- // Output format - SARIF takes precedence over JSON
- if opts.SARIF {
- args = append(args, "--output-format=sarif")
- } else if opts.JSON {
- args = append(args, "--output-format=json")
- }
-
- cmd := exec.CommandContext(ctx, cmdName, args...)
- cmd.Dir = opts.Dir
- cmd.Stdout = opts.Output
- cmd.Stderr = opts.Output
-
- return cmd.Run()
-}
-
-// =============================================================================
-// Security Audit
-// =============================================================================
-
-// AuditOptions configures dependency security auditing.
-type AuditOptions struct {
- Dir string
- JSON bool // Output in JSON format
- Fix bool // Auto-fix vulnerabilities (npm only)
- Output goio.Writer
-}
-
-// AuditResult holds the results of a security audit.
-type AuditResult struct {
- Tool string
- Vulnerabilities int
- Advisories []AuditAdvisory
- Error error
-}
-
-// AuditAdvisory represents a single security advisory.
-type AuditAdvisory struct {
- Package string
- Severity string
- Title string
- URL string
- Identifiers []string
-}
-
-// RunAudit runs security audits on dependencies.
-func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return nil, cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- var results []AuditResult
-
- // Run composer audit
- composerResult := runComposerAudit(ctx, opts)
- results = append(results, composerResult)
-
- // Run npm audit if package.json exists
- if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) {
- npmResult := runNpmAudit(ctx, opts)
- results = append(results, npmResult)
- }
-
- return results, nil
-}
-
-func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult {
- result := AuditResult{Tool: "composer"}
-
- args := []string{"audit", "--format=json"}
-
- cmd := exec.CommandContext(ctx, "composer", args...)
- cmd.Dir = opts.Dir
-
- output, err := cmd.Output()
- if err != nil {
- // composer audit returns non-zero if vulnerabilities found
- if exitErr, ok := err.(*exec.ExitError); ok {
- output = append(output, exitErr.Stderr...)
- }
- }
-
- // Parse JSON output
- var auditData struct {
- Advisories map[string][]struct {
- Title string `json:"title"`
- Link string `json:"link"`
- CVE string `json:"cve"`
- AffectedRanges string `json:"affectedVersions"`
- } `json:"advisories"`
- }
-
- if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
- for pkg, advisories := range auditData.Advisories {
- for _, adv := range advisories {
- result.Advisories = append(result.Advisories, AuditAdvisory{
- Package: pkg,
- Title: adv.Title,
- URL: adv.Link,
- Identifiers: []string{adv.CVE},
- })
- }
- }
- result.Vulnerabilities = len(result.Advisories)
- } else if err != nil {
- result.Error = err
- }
-
- return result
-}
-
-func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult {
- result := AuditResult{Tool: "npm"}
-
- args := []string{"audit", "--json"}
- if opts.Fix {
- args = []string{"audit", "fix"}
- }
-
- cmd := exec.CommandContext(ctx, "npm", args...)
- cmd.Dir = opts.Dir
-
- output, err := cmd.Output()
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok {
- output = append(output, exitErr.Stderr...)
- }
- }
-
- if !opts.Fix {
- // Parse JSON output
- var auditData struct {
- Metadata struct {
- Vulnerabilities struct {
- Total int `json:"total"`
- } `json:"vulnerabilities"`
- } `json:"metadata"`
- Vulnerabilities map[string]struct {
- Severity string `json:"severity"`
- Via []any `json:"via"`
- } `json:"vulnerabilities"`
- }
-
- if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
- result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total
- for pkg, vuln := range auditData.Vulnerabilities {
- result.Advisories = append(result.Advisories, AuditAdvisory{
- Package: pkg,
- Severity: vuln.Severity,
- })
- }
- } else if err != nil {
- result.Error = err
- }
- }
-
- return result
-}
-
-// =============================================================================
-// Rector Automated Refactoring
-// =============================================================================
-
-// RectorOptions configures Rector code refactoring.
-type RectorOptions struct {
- Dir string
- Fix bool // Apply changes (default is dry-run)
- Diff bool // Show detailed diff
- ClearCache bool // Clear cache before running
- Output goio.Writer
-}
-
-// DetectRector checks if Rector is available in the project.
-func DetectRector(dir string) bool {
- m := getMedium()
-
- // Check for rector.php config
- rectorConfig := filepath.Join(dir, "rector.php")
- if m.Exists(rectorConfig) {
- return true
- }
-
- // Check for vendor binary
- rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
- if m.Exists(rectorBin) {
- return true
- }
-
- return false
-}
-
-// RunRector runs Rector for automated code refactoring.
-func RunRector(ctx context.Context, opts RectorOptions) error {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- m := getMedium()
-
- // Build command
- vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
- cmdName := "rector"
- if m.Exists(vendorBin) {
- cmdName = vendorBin
- }
-
- args := []string{"process"}
-
- if !opts.Fix {
- args = append(args, "--dry-run")
- }
-
- if opts.Diff {
- args = append(args, "--output-format", "diff")
- }
-
- if opts.ClearCache {
- args = append(args, "--clear-cache")
- }
-
- cmd := exec.CommandContext(ctx, cmdName, args...)
- cmd.Dir = opts.Dir
- cmd.Stdout = opts.Output
- cmd.Stderr = opts.Output
-
- return cmd.Run()
-}
-
-// =============================================================================
-// Infection Mutation Testing
-// =============================================================================
-
-// InfectionOptions configures Infection mutation testing.
-type InfectionOptions struct {
- Dir string
- MinMSI int // Minimum mutation score indicator (0-100)
- MinCoveredMSI int // Minimum covered mutation score (0-100)
- Threads int // Number of parallel threads
- Filter string // Filter files by pattern
- OnlyCovered bool // Only mutate covered code
- Output goio.Writer
-}
-
-// DetectInfection checks if Infection is available in the project.
-func DetectInfection(dir string) bool {
- m := getMedium()
-
- // Check for infection config files
- configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
- for _, config := range configs {
- if m.Exists(filepath.Join(dir, config)) {
- return true
- }
- }
-
- // Check for vendor binary
- infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
- if m.Exists(infectionBin) {
- return true
- }
-
- return false
-}
-
-// RunInfection runs Infection mutation testing.
-func RunInfection(ctx context.Context, opts InfectionOptions) error {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- if opts.Output == nil {
- opts.Output = os.Stdout
- }
-
- m := getMedium()
-
- // Build command
- vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
- cmdName := "infection"
- if m.Exists(vendorBin) {
- cmdName = vendorBin
- }
-
- var args []string
-
- // Set defaults
- minMSI := opts.MinMSI
- if minMSI == 0 {
- minMSI = 50
- }
- minCoveredMSI := opts.MinCoveredMSI
- if minCoveredMSI == 0 {
- minCoveredMSI = 70
- }
- threads := opts.Threads
- if threads == 0 {
- threads = 4
- }
-
- args = append(args, cli.Sprintf("--min-msi=%d", minMSI))
- args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI))
- args = append(args, cli.Sprintf("--threads=%d", threads))
-
- if opts.Filter != "" {
- args = append(args, "--filter="+opts.Filter)
- }
-
- if opts.OnlyCovered {
- args = append(args, "--only-covered")
- }
-
- cmd := exec.CommandContext(ctx, cmdName, args...)
- cmd.Dir = opts.Dir
- cmd.Stdout = opts.Output
- cmd.Stderr = opts.Output
-
- return cmd.Run()
-}
-
-// =============================================================================
-// QA Pipeline
-// =============================================================================
-
-// QAOptions configures the full QA pipeline.
-type QAOptions struct {
- Dir string
- Quick bool // Only run quick checks
- Full bool // Run all stages including slow checks
- Fix bool // Auto-fix issues where possible
- JSON bool // Output results as JSON
-}
-
-// QAStage represents a stage in the QA pipeline.
-type QAStage string
-
-// QA pipeline stage constants.
-const (
- // QAStageQuick runs fast checks only (audit, fmt, stan).
- QAStageQuick QAStage = "quick"
- // QAStageStandard runs standard checks including tests.
- QAStageStandard QAStage = "standard"
- // QAStageFull runs all checks including slow security scans.
- QAStageFull QAStage = "full"
-)
-
-// QACheckResult holds the result of a single QA check.
-type QACheckResult struct {
- Name string
- Stage QAStage
- Passed bool
- Duration string
- Error error
- Output string
-}
-
-// QAResult holds the results of the full QA pipeline.
-type QAResult struct {
- Stages []QAStage
- Checks []QACheckResult
- Passed bool
- Summary string
-}
-
-// GetQAStages returns the stages to run based on options.
-func GetQAStages(opts QAOptions) []QAStage {
- if opts.Quick {
- return []QAStage{QAStageQuick}
- }
- if opts.Full {
- return []QAStage{QAStageQuick, QAStageStandard, QAStageFull}
- }
- // Default: quick + standard
- return []QAStage{QAStageQuick, QAStageStandard}
-}
-
-// GetQAChecks returns the checks for a given stage.
-func GetQAChecks(dir string, stage QAStage) []string {
- switch stage {
- case QAStageQuick:
- checks := []string{"audit", "fmt", "stan"}
- return checks
- case QAStageStandard:
- checks := []string{}
- if _, found := DetectPsalm(dir); found {
- checks = append(checks, "psalm")
- }
- checks = append(checks, "test")
- return checks
- case QAStageFull:
- checks := []string{}
- if DetectRector(dir) {
- checks = append(checks, "rector")
- }
- if DetectInfection(dir) {
- checks = append(checks, "infection")
- }
- return checks
- }
- return nil
-}
-
-// =============================================================================
-// Security Checks
-// =============================================================================
-
-// SecurityOptions configures security scanning.
-type SecurityOptions struct {
- Dir string
- Severity string // Minimum severity (critical, high, medium, low)
- JSON bool // Output in JSON format
- SARIF bool // Output in SARIF format
- URL string // URL to check HTTP headers (optional)
- Output goio.Writer
-}
-
-// SecurityResult holds the results of security scanning.
-type SecurityResult struct {
- Checks []SecurityCheck
- Summary SecuritySummary
-}
-
-// SecurityCheck represents a single security check result.
-type SecurityCheck struct {
- ID string
- Name string
- Description string
- Severity string
- Passed bool
- Message string
- Fix string
- CWE string
-}
-
-// SecuritySummary summarizes security check results.
-type SecuritySummary struct {
- Total int
- Passed int
- Critical int
- High int
- Medium int
- Low int
-}
-
-// RunSecurityChecks runs security checks on the project.
-func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) {
- if opts.Dir == "" {
- cwd, err := os.Getwd()
- if err != nil {
- return nil, cli.WrapVerb(err, "get", "working directory")
- }
- opts.Dir = cwd
- }
-
- result := &SecurityResult{}
-
- // Run composer audit
- auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir})
- for _, audit := range auditResults {
- check := SecurityCheck{
- ID: audit.Tool + "_audit",
- Name: i18n.Title(audit.Tool) + " Security Audit",
- Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
- Severity: "critical",
- Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
- CWE: "CWE-1395",
- }
- if !check.Passed {
- check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities)
- }
- result.Checks = append(result.Checks, check)
- }
-
- // Check .env file for security issues
- envChecks := runEnvSecurityChecks(opts.Dir)
- result.Checks = append(result.Checks, envChecks...)
-
- // Check filesystem security
- fsChecks := runFilesystemSecurityChecks(opts.Dir)
- result.Checks = append(result.Checks, fsChecks...)
-
- // Calculate summary
- for _, check := range result.Checks {
- result.Summary.Total++
- if check.Passed {
- result.Summary.Passed++
- } else {
- switch check.Severity {
- case "critical":
- result.Summary.Critical++
- case "high":
- result.Summary.High++
- case "medium":
- result.Summary.Medium++
- case "low":
- result.Summary.Low++
- }
- }
- }
-
- return result, nil
-}
-
-func runEnvSecurityChecks(dir string) []SecurityCheck {
- var checks []SecurityCheck
-
- m := getMedium()
- envPath := filepath.Join(dir, ".env")
- envContent, err := m.Read(envPath)
- if err != nil {
- return checks
- }
-
- envLines := strings.Split(envContent, "\n")
- envMap := make(map[string]string)
- for _, line := range envLines {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- parts := strings.SplitN(line, "=", 2)
- if len(parts) == 2 {
- envMap[parts[0]] = parts[1]
- }
- }
-
- // Check APP_DEBUG
- if debug, ok := envMap["APP_DEBUG"]; ok {
- check := SecurityCheck{
- ID: "debug_mode",
- Name: "Debug Mode Disabled",
- Description: "APP_DEBUG should be false in production",
- Severity: "critical",
- Passed: strings.ToLower(debug) != "true",
- CWE: "CWE-215",
- }
- if !check.Passed {
- check.Message = "Debug mode exposes sensitive information"
- check.Fix = "Set APP_DEBUG=false in .env"
- }
- checks = append(checks, check)
- }
-
- // Check APP_KEY
- if key, ok := envMap["APP_KEY"]; ok {
- check := SecurityCheck{
- ID: "app_key_set",
- Name: "Application Key Set",
- Description: "APP_KEY must be set and valid",
- Severity: "critical",
- Passed: len(key) >= 32,
- CWE: "CWE-321",
- }
- if !check.Passed {
- check.Message = "Missing or weak encryption key"
- check.Fix = "Run: php artisan key:generate"
- }
- checks = append(checks, check)
- }
-
- // Check APP_URL for HTTPS
- if url, ok := envMap["APP_URL"]; ok {
- check := SecurityCheck{
- ID: "https_enforced",
- Name: "HTTPS Enforced",
- Description: "APP_URL should use HTTPS in production",
- Severity: "high",
- Passed: strings.HasPrefix(url, "https://"),
- CWE: "CWE-319",
- }
- if !check.Passed {
- check.Message = "Application not using HTTPS"
- check.Fix = "Update APP_URL to use https://"
- }
- checks = append(checks, check)
- }
-
- return checks
-}
-
-func runFilesystemSecurityChecks(dir string) []SecurityCheck {
- var checks []SecurityCheck
- m := getMedium()
-
- // Check .env not in public
- publicEnvPaths := []string{"public/.env", "public_html/.env"}
- for _, path := range publicEnvPaths {
- fullPath := filepath.Join(dir, path)
- if m.Exists(fullPath) {
- checks = append(checks, SecurityCheck{
- ID: "env_not_public",
- Name: ".env Not Publicly Accessible",
- Description: ".env file should not be in public directory",
- Severity: "critical",
- Passed: false,
- Message: "Environment file exposed to web at " + path,
- CWE: "CWE-538",
- })
- }
- }
-
- // Check .git not in public
- publicGitPaths := []string{"public/.git", "public_html/.git"}
- for _, path := range publicGitPaths {
- fullPath := filepath.Join(dir, path)
- if m.Exists(fullPath) {
- checks = append(checks, SecurityCheck{
- ID: "git_not_public",
- Name: ".git Not Publicly Accessible",
- Description: ".git directory should not be in public",
- Severity: "critical",
- Passed: false,
- Message: "Git repository exposed to web (source code leak)",
- CWE: "CWE-538",
- })
- }
- }
-
- return checks
-}
diff --git a/quality_extended_test.go b/quality_extended_test.go
deleted file mode 100644
index 8c1c00e..0000000
--- a/quality_extended_test.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package php
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestFormatOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := FormatOptions{
- Dir: "/project",
- Fix: true,
- Diff: true,
- Paths: []string{"app", "tests"},
- Output: os.Stdout,
- }
-
- assert.Equal(t, "/project", opts.Dir)
- assert.True(t, opts.Fix)
- assert.True(t, opts.Diff)
- assert.Equal(t, []string{"app", "tests"}, opts.Paths)
- assert.NotNil(t, opts.Output)
- })
-}
-
-func TestAnalyseOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := AnalyseOptions{
- Dir: "/project",
- Level: 5,
- Paths: []string{"src"},
- Memory: "2G",
- Output: os.Stdout,
- }
-
- assert.Equal(t, "/project", opts.Dir)
- assert.Equal(t, 5, opts.Level)
- assert.Equal(t, []string{"src"}, opts.Paths)
- assert.Equal(t, "2G", opts.Memory)
- assert.NotNil(t, opts.Output)
- })
-}
-
-func TestFormatterType_Constants(t *testing.T) {
- t.Run("constants are defined", func(t *testing.T) {
- assert.Equal(t, FormatterType("pint"), FormatterPint)
- })
-}
-
-func TestAnalyserType_Constants(t *testing.T) {
- t.Run("constants are defined", func(t *testing.T) {
- assert.Equal(t, AnalyserType("phpstan"), AnalyserPHPStan)
- assert.Equal(t, AnalyserType("larastan"), AnalyserLarastan)
- })
-}
-
-func TestDetectFormatter_Extended(t *testing.T) {
- t.Run("returns not found for empty directory", func(t *testing.T) {
- dir := t.TempDir()
- _, found := DetectFormatter(dir)
- assert.False(t, found)
- })
-
- t.Run("prefers pint.json over vendor binary", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create pint.json
- err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
- require.NoError(t, err)
-
- formatter, found := DetectFormatter(dir)
- assert.True(t, found)
- assert.Equal(t, FormatterPint, formatter)
- })
-}
-
-func TestDetectAnalyser_Extended(t *testing.T) {
- t.Run("returns not found for empty directory", func(t *testing.T) {
- dir := t.TempDir()
- _, found := DetectAnalyser(dir)
- assert.False(t, found)
- })
-
- t.Run("detects phpstan from vendor binary alone", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create vendor binary
- binDir := filepath.Join(dir, "vendor", "bin")
- err := os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
-
- err = os.WriteFile(filepath.Join(binDir, "phpstan"), []byte(""), 0755)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserPHPStan, analyser)
- })
-
- t.Run("detects larastan from larastan/larastan vendor path", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create phpstan.neon
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- // Create larastan/larastan path
- larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
- err = os.MkdirAll(larastanPath, 0755)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserLarastan, analyser)
- })
-
- t.Run("detects larastan from nunomaduro/larastan vendor path", func(t *testing.T) {
- dir := t.TempDir()
-
- // Create phpstan.neon
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- // Create nunomaduro/larastan path
- larastanPath := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
- err = os.MkdirAll(larastanPath, 0755)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserLarastan, analyser)
- })
-}
-
-func TestBuildPintCommand_Extended(t *testing.T) {
- t.Run("uses global pint when no vendor binary", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir}
-
- cmd, _ := buildPintCommand(opts)
- assert.Equal(t, "pint", cmd)
- })
-
- t.Run("adds test flag when Fix is false", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Fix: false}
-
- _, args := buildPintCommand(opts)
- assert.Contains(t, args, "--test")
- })
-
- t.Run("does not add test flag when Fix is true", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Fix: true}
-
- _, args := buildPintCommand(opts)
- assert.NotContains(t, args, "--test")
- })
-
- t.Run("adds diff flag", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Diff: true}
-
- _, args := buildPintCommand(opts)
- assert.Contains(t, args, "--diff")
- })
-
- t.Run("adds paths", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Paths: []string{"app", "tests"}}
-
- _, args := buildPintCommand(opts)
- assert.Contains(t, args, "app")
- assert.Contains(t, args, "tests")
- })
-}
-
-func TestBuildPHPStanCommand_Extended(t *testing.T) {
- t.Run("uses global phpstan when no vendor binary", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir}
-
- cmd, _ := buildPHPStanCommand(opts)
- assert.Equal(t, "phpstan", cmd)
- })
-
- t.Run("adds level flag", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Level: 8}
-
- _, args := buildPHPStanCommand(opts)
- assert.Contains(t, args, "--level")
- assert.Contains(t, args, "8")
- })
-
- t.Run("does not add level flag when zero", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Level: 0}
-
- _, args := buildPHPStanCommand(opts)
- assert.NotContains(t, args, "--level")
- })
-
- t.Run("adds memory limit", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Memory: "4G"}
-
- _, args := buildPHPStanCommand(opts)
- assert.Contains(t, args, "--memory-limit")
- assert.Contains(t, args, "4G")
- })
-
- t.Run("does not add memory flag when empty", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Memory: ""}
-
- _, args := buildPHPStanCommand(opts)
- assert.NotContains(t, args, "--memory-limit")
- })
-
- t.Run("adds paths", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}}
-
- _, args := buildPHPStanCommand(opts)
- assert.Contains(t, args, "src")
- assert.Contains(t, args, "app")
- })
-}
-
-func TestFormat_Bad(t *testing.T) {
- t.Run("fails when no formatter found", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir}
-
- err := Format(context.TODO(), opts)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "no formatter found")
- })
-
- t.Run("uses cwd when dir not specified", func(t *testing.T) {
- // When no formatter found in cwd, should still fail with "no formatter found"
- opts := FormatOptions{Dir: ""}
-
- err := Format(context.TODO(), opts)
- // May or may not find a formatter depending on cwd, but function should not panic
- if err != nil {
- // Expected - no formatter in cwd
- assert.Contains(t, err.Error(), "no formatter")
- }
- })
-
- t.Run("uses stdout when output not specified", func(t *testing.T) {
- dir := t.TempDir()
- // Create pint.json to enable formatter detection
- err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
- require.NoError(t, err)
-
- opts := FormatOptions{Dir: dir, Output: nil}
-
- // Will fail because pint isn't actually installed, but tests the code path
- err = Format(context.Background(), opts)
- assert.Error(t, err) // Pint not installed
- })
-}
-
-func TestAnalyse_Bad(t *testing.T) {
- t.Run("fails when no analyser found", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir}
-
- err := Analyse(context.TODO(), opts)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "no static analyser found")
- })
-
- t.Run("uses cwd when dir not specified", func(t *testing.T) {
- opts := AnalyseOptions{Dir: ""}
-
- err := Analyse(context.TODO(), opts)
- // May or may not find an analyser depending on cwd
- if err != nil {
- assert.Contains(t, err.Error(), "no static analyser")
- }
- })
-
- t.Run("uses stdout when output not specified", func(t *testing.T) {
- dir := t.TempDir()
- // Create phpstan.neon to enable analyser detection
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- opts := AnalyseOptions{Dir: dir, Output: nil}
-
- // Will fail because phpstan isn't actually installed, but tests the code path
- err = Analyse(context.Background(), opts)
- assert.Error(t, err) // PHPStan not installed
- })
-}
diff --git a/quality_test.go b/quality_test.go
deleted file mode 100644
index 710e3fa..0000000
--- a/quality_test.go
+++ /dev/null
@@ -1,517 +0,0 @@
-package php
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestDetectFormatter_Good(t *testing.T) {
- t.Run("detects pint.json", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
- require.NoError(t, err)
-
- formatter, found := DetectFormatter(dir)
- assert.True(t, found)
- assert.Equal(t, FormatterPint, formatter)
- })
-
- t.Run("detects vendor binary", func(t *testing.T) {
- dir := t.TempDir()
- binDir := filepath.Join(dir, "vendor", "bin")
- err := os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- err = os.WriteFile(filepath.Join(binDir, "pint"), []byte(""), 0755)
- require.NoError(t, err)
-
- formatter, found := DetectFormatter(dir)
- assert.True(t, found)
- assert.Equal(t, FormatterPint, formatter)
- })
-}
-
-func TestDetectFormatter_Bad(t *testing.T) {
- t.Run("no formatter", func(t *testing.T) {
- dir := t.TempDir()
- _, found := DetectFormatter(dir)
- assert.False(t, found)
- })
-}
-
-func TestDetectAnalyser_Good(t *testing.T) {
- t.Run("detects phpstan.neon", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserPHPStan, analyser)
- })
-
- t.Run("detects phpstan.neon.dist", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon.dist"), []byte(""), 0644)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserPHPStan, analyser)
- })
-
- t.Run("detects larastan", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- larastanDir := filepath.Join(dir, "vendor", "larastan", "larastan")
- err = os.MkdirAll(larastanDir, 0755)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserLarastan, analyser)
- })
-
- t.Run("detects nunomaduro/larastan", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
- require.NoError(t, err)
-
- larastanDir := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
- err = os.MkdirAll(larastanDir, 0755)
- require.NoError(t, err)
-
- analyser, found := DetectAnalyser(dir)
- assert.True(t, found)
- assert.Equal(t, AnalyserLarastan, analyser)
- })
-}
-
-func TestBuildPintCommand_Good(t *testing.T) {
- t.Run("basic command", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir}
- cmd, args := buildPintCommand(opts)
- assert.Equal(t, "pint", cmd)
- assert.Contains(t, args, "--test")
- })
-
- t.Run("fix enabled", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Fix: true}
- _, args := buildPintCommand(opts)
- assert.NotContains(t, args, "--test")
- })
-
- t.Run("diff enabled", func(t *testing.T) {
- dir := t.TempDir()
- opts := FormatOptions{Dir: dir, Diff: true}
- _, args := buildPintCommand(opts)
- assert.Contains(t, args, "--diff")
- })
-
- t.Run("with specific paths", func(t *testing.T) {
- dir := t.TempDir()
- paths := []string{"app", "tests"}
- opts := FormatOptions{Dir: dir, Paths: paths}
- _, args := buildPintCommand(opts)
- assert.Equal(t, paths, args[len(args)-2:])
- })
-
- t.Run("uses vendor binary if exists", func(t *testing.T) {
- dir := t.TempDir()
- binDir := filepath.Join(dir, "vendor", "bin")
- err := os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- pintPath := filepath.Join(binDir, "pint")
- err = os.WriteFile(pintPath, []byte(""), 0755)
- require.NoError(t, err)
-
- opts := FormatOptions{Dir: dir}
- cmd, _ := buildPintCommand(opts)
- assert.Equal(t, pintPath, cmd)
- })
-}
-
-func TestBuildPHPStanCommand_Good(t *testing.T) {
- t.Run("basic command", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir}
- cmd, args := buildPHPStanCommand(opts)
- assert.Equal(t, "phpstan", cmd)
- assert.Equal(t, []string{"analyse"}, args)
- })
-
- t.Run("with level", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Level: 5}
- _, args := buildPHPStanCommand(opts)
- assert.Contains(t, args, "--level")
- assert.Contains(t, args, "5")
- })
-
- t.Run("with memory limit", func(t *testing.T) {
- dir := t.TempDir()
- opts := AnalyseOptions{Dir: dir, Memory: "2G"}
- _, args := buildPHPStanCommand(opts)
- assert.Contains(t, args, "--memory-limit")
- assert.Contains(t, args, "2G")
- })
-
- t.Run("uses vendor binary if exists", func(t *testing.T) {
- dir := t.TempDir()
- binDir := filepath.Join(dir, "vendor", "bin")
- err := os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- phpstanPath := filepath.Join(binDir, "phpstan")
- err = os.WriteFile(phpstanPath, []byte(""), 0755)
- require.NoError(t, err)
-
- opts := AnalyseOptions{Dir: dir}
- cmd, _ := buildPHPStanCommand(opts)
- assert.Equal(t, phpstanPath, cmd)
- })
-}
-
-// =============================================================================
-// Psalm Detection Tests
-// =============================================================================
-
-func TestDetectPsalm_Good(t *testing.T) {
- t.Run("detects psalm.xml", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644)
- require.NoError(t, err)
-
- // Also need vendor binary for it to return true
- binDir := filepath.Join(dir, "vendor", "bin")
- err = os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
- require.NoError(t, err)
-
- psalmType, found := DetectPsalm(dir)
- assert.True(t, found)
- assert.Equal(t, PsalmStandard, psalmType)
- })
-
- t.Run("detects psalm.xml.dist", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644)
- require.NoError(t, err)
-
- binDir := filepath.Join(dir, "vendor", "bin")
- err = os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
- require.NoError(t, err)
-
- _, found := DetectPsalm(dir)
- assert.True(t, found)
- })
-
- t.Run("detects vendor binary only", func(t *testing.T) {
- dir := t.TempDir()
- binDir := filepath.Join(dir, "vendor", "bin")
- err := os.MkdirAll(binDir, 0755)
- require.NoError(t, err)
- err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
- require.NoError(t, err)
-
- _, found := DetectPsalm(dir)
- assert.True(t, found)
- })
-}
-
-func TestDetectPsalm_Bad(t *testing.T) {
- t.Run("no psalm", func(t *testing.T) {
- dir := t.TempDir()
- _, found := DetectPsalm(dir)
- assert.False(t, found)
- })
-}
-
-// =============================================================================
-// Rector Detection Tests
-// =============================================================================
-
-func TestDetectRector_Good(t *testing.T) {
- t.Run("detects rector.php", func(t *testing.T) {
- dir := t.TempDir()
- err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("withPaths([
+ __DIR__.'/src',
+ ])
+ ->withSkip([
+ __DIR__.'/src/Core/Activity',
+ __DIR__.'/src/Core/Tests',
+ __DIR__.'/src/Mod/Trees',
+ ])
+ ->withSets([
+ // PHP version upgrades
+ LevelSetList::UP_TO_PHP_82,
+
+ // Code quality
+ SetList::CODE_QUALITY,
+ SetList::CODING_STYLE,
+ SetList::DEAD_CODE,
+ SetList::EARLY_RETURN,
+ SetList::TYPE_DECLARATION,
+ ])
+ ->withImportNames(
+ importShortClasses: false,
+ removeUnusedImports: true
+ )
+ ->withPreparedSets(
+ deadCode: true,
+ codeQuality: true,
+ typeDeclarations: true,
+ earlyReturn: true,
+ );
diff --git a/security-checks.yaml b/security-checks.yaml
new file mode 100644
index 0000000..3f78984
--- /dev/null
+++ b/security-checks.yaml
@@ -0,0 +1,536 @@
+# PHP Security Checks Specification
+# For `core php security` command implementation in Go
+#
+# Usage: core php security [--fix] [--json] [--severity=high]
+#
+# This file defines security checks that can be run without PHP runtime
+# by parsing files directly or shelling out to existing tools.
+
+name: PHP Security Checks
+version: 1.0.0
+
+# Severity levels (exit codes)
+severity_levels:
+ critical: 1 # Must fix before deploy
+ high: 2 # Should fix soon
+ medium: 3 # Recommended fix
+ low: 4 # Nice to have
+ info: 0 # Informational only
+
+# =============================================================================
+# ENVIRONMENT CHECKS
+# Parse .env file directly - no PHP needed
+# =============================================================================
+env_checks:
+ - id: debug_mode
+ name: Debug Mode Disabled
+ description: APP_DEBUG must be false in production
+ severity: critical
+ key: APP_DEBUG
+ condition: "!= true"
+ when_env: [production, prod, live, staging]
+ message: "Debug mode exposes sensitive information to users"
+ fix: "Set APP_DEBUG=false in .env"
+ cwe: CWE-215
+
+ - id: app_key_set
+ name: Application Key Set
+ description: APP_KEY must be set and valid
+ severity: critical
+ key: APP_KEY
+ condition: "exists && length >= 32"
+ message: "Missing or weak encryption key"
+ fix: "Run: php artisan key:generate"
+ cwe: CWE-321
+
+ - id: app_key_not_default
+ name: Application Key Not Default
+ description: APP_KEY must not be a known default value
+ severity: critical
+ key: APP_KEY
+ condition: "not_in"
+ bad_values:
+ - "base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ - "SomeRandomString"
+ message: "Using default or example APP_KEY"
+ cwe: CWE-798
+
+ - id: secure_cookies
+ name: Secure Cookies Enabled
+ description: SESSION_SECURE_COOKIE should be true for HTTPS
+ severity: high
+ key: SESSION_SECURE_COOKIE
+ condition: "== true"
+ when_env: [production, prod, live]
+ message: "Cookies can be intercepted over HTTP"
+ fix: "Set SESSION_SECURE_COOKIE=true"
+ cwe: CWE-614
+
+ - id: session_http_only
+ name: HTTP-Only Cookies
+ description: Cookies should not be accessible via JavaScript
+ severity: high
+ key: SESSION_HTTP_ONLY
+ condition: "== true"
+ default_good: true # Laravel default is true
+ message: "Cookies accessible to JavaScript (XSS risk)"
+ cwe: CWE-1004
+
+ - id: session_same_site
+ name: SameSite Cookie Attribute
+ description: SESSION_SAME_SITE should be 'lax' or 'strict'
+ severity: medium
+ key: SESSION_SAME_SITE
+ condition: "in"
+ good_values: [lax, strict]
+ message: "Missing CSRF protection via SameSite attribute"
+ cwe: CWE-1275
+
+ - id: https_only
+ name: HTTPS Enforced
+ description: APP_URL should use HTTPS in production
+ severity: high
+ key: APP_URL
+ condition: "starts_with https://"
+ when_env: [production, prod, live]
+ message: "Application not enforcing HTTPS"
+ cwe: CWE-319
+
+ - id: mail_encryption
+ name: Mail Encryption Enabled
+ description: MAIL_ENCRYPTION should be set for secure email
+ severity: medium
+ key: MAIL_ENCRYPTION
+ condition: "in"
+ good_values: [tls, ssl, starttls]
+ when_key_exists: MAIL_HOST
+ message: "Email sent without encryption"
+ cwe: CWE-319
+
+ - id: db_password_set
+ name: Database Password Set
+ description: DB_PASSWORD should not be empty in production
+ severity: critical
+ key: DB_PASSWORD
+ condition: "exists && not_empty"
+ when_env: [production, prod, live]
+ message: "Database has no password"
+ cwe: CWE-521
+
+ - id: redis_password
+ name: Redis Password Set
+ description: REDIS_PASSWORD should be set if Redis is used
+ severity: high
+ key: REDIS_PASSWORD
+ condition: "exists && not_empty"
+ when_key_exists: REDIS_HOST
+ when_env: [production, prod, live]
+ message: "Redis accessible without authentication"
+ cwe: CWE-306
+
+ - id: log_level_production
+ name: Log Level Appropriate
+ description: LOG_LEVEL should not be 'debug' in production
+ severity: medium
+ key: LOG_LEVEL
+ condition: "not_in"
+ bad_values: [debug]
+ when_env: [production, prod, live]
+ message: "Verbose logging may expose sensitive data"
+ cwe: CWE-532
+
+ - id: telescope_disabled
+ name: Telescope Disabled in Production
+ description: TELESCOPE_ENABLED should be false in production
+ severity: high
+ key: TELESCOPE_ENABLED
+ condition: "!= true"
+ when_env: [production, prod, live]
+ message: "Telescope exposes application internals"
+ cwe: CWE-215
+
+ - id: debugbar_disabled
+ name: Debugbar Disabled in Production
+ description: DEBUGBAR_ENABLED should be false in production
+ severity: high
+ key: DEBUGBAR_ENABLED
+ condition: "!= true"
+ when_env: [production, prod, live]
+ message: "Debugbar exposes sensitive debug information"
+ cwe: CWE-215
+
+# =============================================================================
+# FILE SYSTEM CHECKS
+# Use Go's os package - no PHP needed
+# =============================================================================
+filesystem_checks:
+ - id: env_not_public
+ name: .env Not Publicly Accessible
+ description: .env file should not be in public directory
+ severity: critical
+ check: file_not_exists
+ paths:
+ - public/.env
+ - public_html/.env
+ - www/.env
+ message: "Environment file exposed to web"
+ cwe: CWE-538
+
+ - id: env_permissions
+ name: .env File Permissions
+ description: .env should not be world-readable
+ severity: high
+ check: file_permissions
+ path: .env
+ max_mode: "0640" # rw-r----- or stricter
+ message: ".env file is world-readable"
+ cwe: CWE-732
+
+ - id: storage_permissions
+ name: Storage Directory Writable
+ description: storage/ must be writable but not world-writable
+ severity: medium
+ check: dir_permissions
+ path: storage
+ min_mode: "0755"
+ max_mode: "0775"
+ message: "Storage directory has insecure permissions"
+ cwe: CWE-732
+
+ - id: no_git_public
+ name: .git Not Publicly Accessible
+ description: .git directory should not be in public
+ severity: critical
+ check: dir_not_exists
+ paths:
+ - public/.git
+ - public_html/.git
+ message: "Git repository exposed to web (source code leak)"
+ cwe: CWE-538
+
+ - id: no_sensitive_files_public
+ name: No Sensitive Files in Public
+ description: Sensitive files should not be in public directory
+ severity: critical
+ check: files_not_exist
+ patterns:
+ - "public/*.sql"
+ - "public/*.sqlite"
+ - "public/*.log"
+ - "public/*.bak"
+ - "public/*.env*"
+ - "public/composer.json"
+ - "public/composer.lock"
+ - "public/package.json"
+ message: "Sensitive files exposed to web"
+ cwe: CWE-538
+
+ - id: bootstrap_cache_writable
+ name: Bootstrap Cache Writable
+ description: bootstrap/cache must be writable
+ severity: medium
+ check: dir_writable
+ path: bootstrap/cache
+ message: "Bootstrap cache not writable (deployment issues)"
+
+# =============================================================================
+# CONFIG FILE CHECKS
+# Parse PHP config files with regex - no PHP runtime needed
+# =============================================================================
+config_checks:
+ - id: csrf_middleware
+ name: CSRF Middleware Enabled
+ description: VerifyCsrfToken middleware must be active
+ severity: critical
+ check: pattern_exists
+ files:
+ - app/Http/Kernel.php
+ - bootstrap/app.php
+ patterns:
+ - "VerifyCsrfToken"
+ - "ValidateCsrfToken"
+ message: "CSRF protection not enabled"
+ cwe: CWE-352
+
+ - id: auth_throttle
+ name: Login Throttling Enabled
+ description: Rate limiting should be applied to auth routes
+ severity: high
+ check: pattern_exists
+ files:
+ - routes/auth.php
+ - routes/web.php
+ - app/Http/Kernel.php
+ patterns:
+ - "throttle:"
+ - "RateLimiter"
+ - "ThrottleRequests"
+ message: "No rate limiting on authentication routes"
+ cwe: CWE-307
+
+ - id: bcrypt_or_argon
+ name: Strong Password Hashing
+ description: Password hashing should use bcrypt or argon2
+ severity: high
+ check: config_value
+ file: config/hashing.php
+ key: driver
+ good_values: [bcrypt, argon, argon2id]
+ message: "Weak password hashing algorithm"
+ cwe: CWE-916
+
+ - id: session_driver_secure
+ name: Secure Session Driver
+ description: Session driver should not be 'array' in production
+ severity: high
+ check: env_or_config
+ env_key: SESSION_DRIVER
+ config_file: config/session.php
+ config_key: driver
+ bad_values: [array]
+ when_env: [production, prod, live]
+ message: "Session driver 'array' loses sessions on restart"
+ cwe: CWE-384
+
+# =============================================================================
+# STATIC PATTERN CHECKS
+# Grep/regex through source files - no PHP needed
+# =============================================================================
+pattern_checks:
+ - id: blade_unescaped
+ name: Unescaped Blade Output
+ description: "{!! !!}" can lead to XSS if used with user input
+ severity: high
+ check: pattern_warning
+ paths:
+ - "resources/views/**/*.blade.php"
+ pattern: '\{!!\s*\$(?!__env|app|config|errors)'
+ exclude_patterns:
+ - '\{!!\s*\$slot' # Slots are safe
+ - '\{!!\s*html_entity_decode' # Intentional
+ message: "Unescaped output may cause XSS vulnerability"
+ cwe: CWE-79
+
+ - id: raw_sql_input
+ name: Raw SQL with User Input
+ description: DB::raw() with user input is SQL injection risk
+ severity: critical
+ check: pattern_search
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ patterns:
+ - 'DB::raw\s*\(\s*["\'].*\$(?:request|_GET|_POST|input)'
+ - 'whereRaw\s*\(\s*["\'].*\$(?:request|_GET|_POST|input)'
+ - 'selectRaw\s*\(\s*["\'].*\$(?:request|_GET|_POST|input)'
+ message: "Possible SQL injection with raw query"
+ cwe: CWE-89
+
+ - id: dangerous_functions
+ name: No Dangerous Function Usage
+ description: Certain PHP functions should never be used
+ severity: critical
+ check: pattern_forbidden
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ patterns:
+ - '\b(create_function|assert)\s*\('
+ message: "Dangerous function allows arbitrary code execution"
+ cwe: CWE-94
+
+ - id: shell_exec_input
+ name: Shell Execution with User Input
+ description: shell_exec/exec with user input is command injection
+ severity: critical
+ check: pattern_search
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ patterns:
+ - '(?:shell_exec|exec|system|passthru|popen)\s*\([^)]*\$(?:request|_GET|_POST|input)'
+ message: "Possible command injection vulnerability"
+ cwe: CWE-78
+
+ - id: unserialize_usage
+ name: Unsafe unserialize()
+ description: unserialize() with user input leads to object injection
+ severity: critical
+ check: pattern_search
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ patterns:
+ - '\bunserialize\s*\(\s*\$(?:request|_GET|_POST|input)'
+ message: "Possible PHP object injection via unserialize()"
+ cwe: CWE-502
+
+ - id: mass_assignment_unguarded
+ name: Unguarded Models
+ description: Models should have $fillable or $guarded defined
+ severity: high
+ check: model_guard
+ paths:
+ - "app/Models/**/*.php"
+ - "src/**/Models/**/*.php"
+ must_have_one_of:
+ - 'protected\s+\$fillable\s*='
+ - 'protected\s+\$guarded\s*='
+ base_class: "extends Model"
+ message: "Model has no mass assignment protection"
+ cwe: CWE-915
+
+ - id: hardcoded_credentials
+ name: No Hardcoded Credentials
+ description: Passwords and secrets should not be in code
+ severity: critical
+ check: pattern_forbidden
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ - "config/**/*.php"
+ patterns:
+ - '(?:password|secret|api_key|apikey|token)\s*[=:]\s*["\'][^"\']{8,}["\']'
+ exclude_patterns:
+ - 'env\s*\(' # Using env() is fine
+ - 'config\s*\(' # Using config() is fine
+ - '@param|@var|@return' # PHPDoc
+ message: "Hardcoded credentials found in source code"
+ cwe: CWE-798
+
+ - id: debug_functions
+ name: No Debug Functions in Production Code
+ description: dd(), dump(), var_dump() should not be in production
+ severity: medium
+ check: pattern_forbidden
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ exclude_paths:
+ - "**/Tests/**"
+ - "**/test/**"
+ patterns:
+ - '\b(?:dd|dump|var_dump|print_r|var_export)\s*\('
+ message: "Debug function found in production code"
+ cwe: CWE-489
+
+ - id: error_display
+ name: No Direct Error Display
+ description: Errors should not be displayed directly to users
+ severity: medium
+ check: pattern_forbidden
+ paths:
+ - "app/**/*.php"
+ - "src/**/*.php"
+ patterns:
+ - 'ini_set\s*\(\s*["\']display_errors["\']\s*,\s*["\']?(?:1|on|true)'
+ - 'error_reporting\s*\(\s*E_ALL\s*\)'
+ message: "Direct error display exposes sensitive information"
+ cwe: CWE-209
+
+# =============================================================================
+# EXTERNAL TOOL CHECKS
+# Shell out to existing tools
+# =============================================================================
+tool_checks:
+ - id: composer_audit
+ name: Composer Security Audit
+ description: Check PHP dependencies for known vulnerabilities
+ severity: critical
+ command: composer audit --format=json
+ success_exit_code: 0
+ parse: json
+ error_path: advisories
+ message: "Vulnerable PHP dependencies found"
+ cwe: CWE-1395
+
+ - id: npm_audit
+ name: NPM Security Audit
+ description: Check JS dependencies for known vulnerabilities
+ severity: high
+ command: npm audit --json
+ success_exit_code: 0
+ parse: json
+ error_path: vulnerabilities
+ when_file_exists: package.json
+ message: "Vulnerable JavaScript dependencies found"
+ cwe: CWE-1395
+
+ - id: phpstan_security
+ name: PHPStan Security Analysis
+ description: Run PHPStan for security-related issues
+ severity: high
+ command: ./vendor/bin/phpstan analyse --error-format=json --no-progress
+ success_exit_code: 0
+ parse: json
+ error_path: totals.file_errors
+ message: "Static analysis found potential issues"
+
+# =============================================================================
+# HEADER CHECKS (for deployed apps)
+# Requires HTTP access - optional check
+# =============================================================================
+header_checks:
+ - id: hsts_header
+ name: HSTS Header Present
+ description: Strict-Transport-Security header should be set
+ severity: high
+ header: Strict-Transport-Security
+ condition: exists
+ when: url_provided
+ message: "Missing HSTS header (HTTPS downgrade attacks possible)"
+ cwe: CWE-319
+
+ - id: content_type_options
+ name: X-Content-Type-Options Header
+ description: Prevent MIME type sniffing
+ severity: medium
+ header: X-Content-Type-Options
+ expected: nosniff
+ when: url_provided
+ message: "Missing X-Content-Type-Options header"
+ cwe: CWE-693
+
+ - id: frame_options
+ name: X-Frame-Options Header
+ description: Prevent clickjacking attacks
+ severity: medium
+ header: X-Frame-Options
+ condition: "in"
+ good_values: [DENY, SAMEORIGIN]
+ when: url_provided
+ message: "Missing clickjacking protection"
+ cwe: CWE-1021
+
+ - id: csp_header
+ name: Content-Security-Policy Header
+ description: CSP helps prevent XSS attacks
+ severity: medium
+ header: Content-Security-Policy
+ condition: exists
+ when: url_provided
+ message: "Missing Content-Security-Policy header"
+ cwe: CWE-693
+
+# =============================================================================
+# OUTPUT FORMAT
+# =============================================================================
+output:
+ formats:
+ - text # Human readable (default)
+ - json # Machine readable
+ - sarif # GitHub/GitLab security format
+ - markdown # For PR comments
+
+# =============================================================================
+# CI INTEGRATION
+# =============================================================================
+ci:
+ # Fail CI if any of these severities found
+ fail_on: [critical, high]
+
+ # GitHub Actions annotation format
+ github_annotations: true
+
+ # GitLab code quality report
+ gitlab_codequality: true
diff --git a/services.go b/services.go
deleted file mode 100644
index 9282ece..0000000
--- a/services.go
+++ /dev/null
@@ -1,486 +0,0 @@
-// Package php provides Laravel/PHP development environment management.
-package php
-
-import (
- "bufio"
- "context"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "time"
-
- "forge.lthn.ai/core/go/pkg/cli"
-)
-
-// Service represents a managed development service.
-type Service interface {
- // Name returns the service name.
- Name() string
- // Start starts the service.
- Start(ctx context.Context) error
- // Stop stops the service gracefully.
- Stop() error
- // Logs returns a reader for the service logs.
- Logs(follow bool) (io.ReadCloser, error)
- // Status returns the current service status.
- Status() ServiceStatus
-}
-
-// ServiceStatus represents the status of a service.
-type ServiceStatus struct {
- Name string
- Running bool
- PID int
- Port int
- Error error
-}
-
-// baseService provides common functionality for all services.
-type baseService struct {
- name string
- port int
- dir string
- cmd *exec.Cmd
- logFile *os.File
- logPath string
- mu sync.RWMutex
- running bool
- lastError error
-}
-
-func (s *baseService) Name() string {
- return s.name
-}
-
-func (s *baseService) Status() ServiceStatus {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- status := ServiceStatus{
- Name: s.name,
- Running: s.running,
- Port: s.port,
- Error: s.lastError,
- }
-
- if s.cmd != nil && s.cmd.Process != nil {
- status.PID = s.cmd.Process.Pid
- }
-
- return status
-}
-
-func (s *baseService) Logs(follow bool) (io.ReadCloser, error) {
- if s.logPath == "" {
- return nil, cli.Err("no log file available for %s", s.name)
- }
-
- m := getMedium()
- file, err := m.Open(s.logPath)
- if err != nil {
- return nil, cli.WrapVerb(err, "open", "log file")
- }
-
- if !follow {
- return file.(io.ReadCloser), nil
- }
-
- // For follow mode, return a tailing reader
- // Type assert to get the underlying *os.File for tailing
- osFile, ok := file.(*os.File)
- if !ok {
- file.Close()
- return nil, cli.Err("log file is not a regular file")
- }
- return newTailReader(osFile), nil
-}
-
-func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if s.running {
- return cli.Err("%s is already running", s.name)
- }
-
- // Create log file
- m := getMedium()
- logDir := filepath.Join(s.dir, ".core", "logs")
- if err := m.EnsureDir(logDir); err != nil {
- return cli.WrapVerb(err, "create", "log directory")
- }
-
- s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name)))
- logWriter, err := m.Create(s.logPath)
- if err != nil {
- return cli.WrapVerb(err, "create", "log file")
- }
- // Type assert to get the underlying *os.File for use with exec.Cmd
- logFile, ok := logWriter.(*os.File)
- if !ok {
- logWriter.Close()
- return cli.Err("log file is not a regular file")
- }
- s.logFile = logFile
-
- // Create command
- s.cmd = exec.CommandContext(ctx, cmdName, args...)
- s.cmd.Dir = s.dir
- s.cmd.Stdout = logFile
- s.cmd.Stderr = logFile
- s.cmd.Env = append(os.Environ(), env...)
-
- // Set platform-specific process attributes for clean shutdown
- setSysProcAttr(s.cmd)
-
- if err := s.cmd.Start(); err != nil {
- _ = logFile.Close()
- s.lastError = err
- return cli.WrapVerb(err, "start", s.name)
- }
-
- s.running = true
- s.lastError = nil
-
- // Monitor process in background
- go func() {
- err := s.cmd.Wait()
- s.mu.Lock()
- s.running = false
- if err != nil {
- s.lastError = err
- }
- if s.logFile != nil {
- _ = s.logFile.Close()
- }
- s.mu.Unlock()
- }()
-
- return nil
-}
-
-func (s *baseService) stopProcess() error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if !s.running || s.cmd == nil || s.cmd.Process == nil {
- return nil
- }
-
- // Send termination signal to process (group on Unix)
- _ = signalProcessGroup(s.cmd, termSignal())
-
- // Wait for graceful shutdown with timeout
- done := make(chan struct{})
- go func() {
- _ = s.cmd.Wait()
- close(done)
- }()
-
- select {
- case <-done:
- // Process exited gracefully
- case <-time.After(5 * time.Second):
- // Force kill
- _ = signalProcessGroup(s.cmd, killSignal())
- }
-
- s.running = false
- return nil
-}
-
-// FrankenPHPService manages the FrankenPHP/Octane server.
-type FrankenPHPService struct {
- baseService
- https bool
- httpsPort int
- certFile string
- keyFile string
-}
-
-// NewFrankenPHPService creates a new FrankenPHP service.
-func NewFrankenPHPService(dir string, opts FrankenPHPOptions) *FrankenPHPService {
- port := opts.Port
- if port == 0 {
- port = 8000
- }
- httpsPort := opts.HTTPSPort
- if httpsPort == 0 {
- httpsPort = 443
- }
-
- return &FrankenPHPService{
- baseService: baseService{
- name: "FrankenPHP",
- port: port,
- dir: dir,
- },
- https: opts.HTTPS,
- httpsPort: httpsPort,
- certFile: opts.CertFile,
- keyFile: opts.KeyFile,
- }
-}
-
-// FrankenPHPOptions configures the FrankenPHP service.
-type FrankenPHPOptions struct {
- Port int
- HTTPSPort int
- HTTPS bool
- CertFile string
- KeyFile string
-}
-
-// Start launches the FrankenPHP Octane server.
-func (s *FrankenPHPService) Start(ctx context.Context) error {
- args := []string{
- "artisan", "octane:start",
- "--server=frankenphp",
- cli.Sprintf("--port=%d", s.port),
- "--no-interaction",
- }
-
- if s.https && s.certFile != "" && s.keyFile != "" {
- args = append(args,
- cli.Sprintf("--https-port=%d", s.httpsPort),
- cli.Sprintf("--https-certificate=%s", s.certFile),
- cli.Sprintf("--https-certificate-key=%s", s.keyFile),
- )
- }
-
- return s.startProcess(ctx, "php", args, nil)
-}
-
-// Stop terminates the FrankenPHP server process.
-func (s *FrankenPHPService) Stop() error {
- return s.stopProcess()
-}
-
-// ViteService manages the Vite development server.
-type ViteService struct {
- baseService
- packageManager string
-}
-
-// NewViteService creates a new Vite service.
-func NewViteService(dir string, opts ViteOptions) *ViteService {
- port := opts.Port
- if port == 0 {
- port = 5173
- }
-
- pm := opts.PackageManager
- if pm == "" {
- pm = DetectPackageManager(dir)
- }
-
- return &ViteService{
- baseService: baseService{
- name: "Vite",
- port: port,
- dir: dir,
- },
- packageManager: pm,
- }
-}
-
-// ViteOptions configures the Vite service.
-type ViteOptions struct {
- Port int
- PackageManager string
-}
-
-// Start launches the Vite development server.
-func (s *ViteService) Start(ctx context.Context) error {
- var cmdName string
- var args []string
-
- switch s.packageManager {
- case "bun":
- cmdName = "bun"
- args = []string{"run", "dev"}
- case "pnpm":
- cmdName = "pnpm"
- args = []string{"run", "dev"}
- case "yarn":
- cmdName = "yarn"
- args = []string{"dev"}
- default:
- cmdName = "npm"
- args = []string{"run", "dev"}
- }
-
- return s.startProcess(ctx, cmdName, args, nil)
-}
-
-// Stop terminates the Vite development server.
-func (s *ViteService) Stop() error {
- return s.stopProcess()
-}
-
-// HorizonService manages Laravel Horizon.
-type HorizonService struct {
- baseService
-}
-
-// NewHorizonService creates a new Horizon service.
-func NewHorizonService(dir string) *HorizonService {
- return &HorizonService{
- baseService: baseService{
- name: "Horizon",
- port: 0, // Horizon doesn't expose a port directly
- dir: dir,
- },
- }
-}
-
-// Start launches the Laravel Horizon queue worker.
-func (s *HorizonService) Start(ctx context.Context) error {
- return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
-}
-
-// Stop terminates Horizon using its terminate command.
-func (s *HorizonService) Stop() error {
- // Horizon has its own terminate command
- cmd := exec.Command("php", "artisan", "horizon:terminate")
- cmd.Dir = s.dir
- _ = cmd.Run() // Ignore errors, will also kill via signal
-
- return s.stopProcess()
-}
-
-// ReverbService manages Laravel Reverb WebSocket server.
-type ReverbService struct {
- baseService
-}
-
-// NewReverbService creates a new Reverb service.
-func NewReverbService(dir string, opts ReverbOptions) *ReverbService {
- port := opts.Port
- if port == 0 {
- port = 8080
- }
-
- return &ReverbService{
- baseService: baseService{
- name: "Reverb",
- port: port,
- dir: dir,
- },
- }
-}
-
-// ReverbOptions configures the Reverb service.
-type ReverbOptions struct {
- Port int
-}
-
-// Start launches the Laravel Reverb WebSocket server.
-func (s *ReverbService) Start(ctx context.Context) error {
- args := []string{
- "artisan", "reverb:start",
- cli.Sprintf("--port=%d", s.port),
- }
-
- return s.startProcess(ctx, "php", args, nil)
-}
-
-// Stop terminates the Reverb WebSocket server.
-func (s *ReverbService) Stop() error {
- return s.stopProcess()
-}
-
-// RedisService manages a local Redis server.
-type RedisService struct {
- baseService
- configFile string
-}
-
-// NewRedisService creates a new Redis service.
-func NewRedisService(dir string, opts RedisOptions) *RedisService {
- port := opts.Port
- if port == 0 {
- port = 6379
- }
-
- return &RedisService{
- baseService: baseService{
- name: "Redis",
- port: port,
- dir: dir,
- },
- configFile: opts.ConfigFile,
- }
-}
-
-// RedisOptions configures the Redis service.
-type RedisOptions struct {
- Port int
- ConfigFile string
-}
-
-// Start launches the Redis server.
-func (s *RedisService) Start(ctx context.Context) error {
- args := []string{
- "--port", cli.Sprintf("%d", s.port),
- "--daemonize", "no",
- }
-
- if s.configFile != "" {
- args = []string{s.configFile}
- args = append(args, "--port", cli.Sprintf("%d", s.port), "--daemonize", "no")
- }
-
- return s.startProcess(ctx, "redis-server", args, nil)
-}
-
-// Stop terminates Redis using the shutdown command.
-func (s *RedisService) Stop() error {
- // Try graceful shutdown via redis-cli
- cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave")
- _ = cmd.Run() // Ignore errors
-
- return s.stopProcess()
-}
-
-// tailReader wraps a file and provides tailing functionality.
-type tailReader struct {
- file *os.File
- reader *bufio.Reader
- closed bool
- mu sync.RWMutex
-}
-
-func newTailReader(file *os.File) *tailReader {
- return &tailReader{
- file: file,
- reader: bufio.NewReader(file),
- }
-}
-
-func (t *tailReader) Read(p []byte) (n int, err error) {
- t.mu.RLock()
- if t.closed {
- t.mu.RUnlock()
- return 0, io.EOF
- }
- t.mu.RUnlock()
-
- n, err = t.reader.Read(p)
- if err == io.EOF {
- // Wait a bit and try again (tailing behavior)
- time.Sleep(100 * time.Millisecond)
- return 0, nil
- }
- return n, err
-}
-
-func (t *tailReader) Close() error {
- t.mu.Lock()
- t.closed = true
- t.mu.Unlock()
- return t.file.Close()
-}
diff --git a/services_extended_test.go b/services_extended_test.go
deleted file mode 100644
index ce3b72e..0000000
--- a/services_extended_test.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package php
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestBaseService_Name_Good(t *testing.T) {
- t.Run("returns service name", func(t *testing.T) {
- s := &baseService{name: "TestService"}
- assert.Equal(t, "TestService", s.Name())
- })
-}
-
-func TestBaseService_Status_Good(t *testing.T) {
- t.Run("returns status when not running", func(t *testing.T) {
- s := &baseService{
- name: "TestService",
- port: 8080,
- running: false,
- }
-
- status := s.Status()
- assert.Equal(t, "TestService", status.Name)
- assert.Equal(t, 8080, status.Port)
- assert.False(t, status.Running)
- assert.Equal(t, 0, status.PID)
- })
-
- t.Run("returns status when running", func(t *testing.T) {
- s := &baseService{
- name: "TestService",
- port: 8080,
- running: true,
- }
-
- status := s.Status()
- assert.True(t, status.Running)
- })
-
- t.Run("returns error in status", func(t *testing.T) {
- testErr := assert.AnError
- s := &baseService{
- name: "TestService",
- lastError: testErr,
- }
-
- status := s.Status()
- assert.Equal(t, testErr, status.Error)
- })
-}
-
-func TestBaseService_Logs_Good(t *testing.T) {
- t.Run("returns log file content", func(t *testing.T) {
- dir := t.TempDir()
- logPath := filepath.Join(dir, "test.log")
- err := os.WriteFile(logPath, []byte("test log content"), 0644)
- require.NoError(t, err)
-
- s := &baseService{logPath: logPath}
- reader, err := s.Logs(false)
-
- assert.NoError(t, err)
- assert.NotNil(t, reader)
- _ = reader.Close()
- })
-
- t.Run("returns tail reader in follow mode", func(t *testing.T) {
- dir := t.TempDir()
- logPath := filepath.Join(dir, "test.log")
- err := os.WriteFile(logPath, []byte("test log content"), 0644)
- require.NoError(t, err)
-
- s := &baseService{logPath: logPath}
- reader, err := s.Logs(true)
-
- assert.NoError(t, err)
- assert.NotNil(t, reader)
- // Verify it's a tailReader by checking it implements ReadCloser
- _, ok := reader.(*tailReader)
- assert.True(t, ok)
- _ = reader.Close()
- })
-}
-
-func TestBaseService_Logs_Bad(t *testing.T) {
- t.Run("returns error when no log path", func(t *testing.T) {
- s := &baseService{name: "TestService"}
- _, err := s.Logs(false)
-
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "no log file available")
- })
-
- t.Run("returns error when log file doesn't exist", func(t *testing.T) {
- s := &baseService{logPath: "/nonexistent/path/log.log"}
- _, err := s.Logs(false)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "Failed to open log file")
- })
-}
-
-func TestTailReader_Good(t *testing.T) {
- t.Run("creates new tail reader", func(t *testing.T) {
- dir := t.TempDir()
- logPath := filepath.Join(dir, "test.log")
- err := os.WriteFile(logPath, []byte("content"), 0644)
- require.NoError(t, err)
-
- file, err := os.Open(logPath)
- require.NoError(t, err)
- defer func() { _ = file.Close() }()
-
- reader := newTailReader(file)
- assert.NotNil(t, reader)
- assert.NotNil(t, reader.file)
- assert.NotNil(t, reader.reader)
- assert.False(t, reader.closed)
- })
-
- t.Run("closes file on Close", func(t *testing.T) {
- dir := t.TempDir()
- logPath := filepath.Join(dir, "test.log")
- err := os.WriteFile(logPath, []byte("content"), 0644)
- require.NoError(t, err)
-
- file, err := os.Open(logPath)
- require.NoError(t, err)
-
- reader := newTailReader(file)
- err = reader.Close()
- assert.NoError(t, err)
- assert.True(t, reader.closed)
- })
-
- t.Run("returns EOF when closed", func(t *testing.T) {
- dir := t.TempDir()
- logPath := filepath.Join(dir, "test.log")
- err := os.WriteFile(logPath, []byte("content"), 0644)
- require.NoError(t, err)
-
- file, err := os.Open(logPath)
- require.NoError(t, err)
-
- reader := newTailReader(file)
- _ = reader.Close()
-
- buf := make([]byte, 100)
- n, _ := reader.Read(buf)
- // When closed, should return 0 bytes (the closed flag causes early return)
- assert.Equal(t, 0, n)
- })
-}
-
-func TestFrankenPHPService_Extended(t *testing.T) {
- t.Run("all options set correctly", func(t *testing.T) {
- opts := FrankenPHPOptions{
- Port: 9000,
- HTTPSPort: 9443,
- HTTPS: true,
- CertFile: "/path/to/cert.pem",
- KeyFile: "/path/to/key.pem",
- }
-
- service := NewFrankenPHPService("/project", opts)
-
- assert.Equal(t, "FrankenPHP", service.Name())
- assert.Equal(t, 9000, service.port)
- assert.Equal(t, 9443, service.httpsPort)
- assert.True(t, service.https)
- assert.Equal(t, "/path/to/cert.pem", service.certFile)
- assert.Equal(t, "/path/to/key.pem", service.keyFile)
- assert.Equal(t, "/project", service.dir)
- })
-}
-
-func TestViteService_Extended(t *testing.T) {
- t.Run("auto-detects package manager", func(t *testing.T) {
- dir := t.TempDir()
- // Create bun.lockb to trigger bun detection
- err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
- require.NoError(t, err)
-
- service := NewViteService(dir, ViteOptions{})
-
- assert.Equal(t, "bun", service.packageManager)
- })
-
- t.Run("uses provided package manager", func(t *testing.T) {
- dir := t.TempDir()
-
- service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
-
- assert.Equal(t, "pnpm", service.packageManager)
- })
-}
-
-func TestHorizonService_Extended(t *testing.T) {
- t.Run("has zero port", func(t *testing.T) {
- service := NewHorizonService("/project")
- assert.Equal(t, 0, service.port)
- })
-}
-
-func TestReverbService_Extended(t *testing.T) {
- t.Run("uses default port 8080", func(t *testing.T) {
- service := NewReverbService("/project", ReverbOptions{})
- assert.Equal(t, 8080, service.port)
- })
-
- t.Run("uses custom port", func(t *testing.T) {
- service := NewReverbService("/project", ReverbOptions{Port: 9090})
- assert.Equal(t, 9090, service.port)
- })
-}
-
-func TestRedisService_Extended(t *testing.T) {
- t.Run("uses default port 6379", func(t *testing.T) {
- service := NewRedisService("/project", RedisOptions{})
- assert.Equal(t, 6379, service.port)
- })
-
- t.Run("accepts config file", func(t *testing.T) {
- service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"})
- assert.Equal(t, "/path/to/redis.conf", service.configFile)
- })
-}
-
-func TestServiceStatus_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- testErr := assert.AnError
- status := ServiceStatus{
- Name: "TestService",
- Running: true,
- PID: 12345,
- Port: 8080,
- Error: testErr,
- }
-
- assert.Equal(t, "TestService", status.Name)
- assert.True(t, status.Running)
- assert.Equal(t, 12345, status.PID)
- assert.Equal(t, 8080, status.Port)
- assert.Equal(t, testErr, status.Error)
- })
-}
-
-func TestFrankenPHPOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := FrankenPHPOptions{
- Port: 8000,
- HTTPSPort: 443,
- HTTPS: true,
- CertFile: "cert.pem",
- KeyFile: "key.pem",
- }
-
- assert.Equal(t, 8000, opts.Port)
- assert.Equal(t, 443, opts.HTTPSPort)
- assert.True(t, opts.HTTPS)
- assert.Equal(t, "cert.pem", opts.CertFile)
- assert.Equal(t, "key.pem", opts.KeyFile)
- })
-}
-
-func TestViteOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := ViteOptions{
- Port: 5173,
- PackageManager: "bun",
- }
-
- assert.Equal(t, 5173, opts.Port)
- assert.Equal(t, "bun", opts.PackageManager)
- })
-}
-
-func TestReverbOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := ReverbOptions{Port: 8080}
- assert.Equal(t, 8080, opts.Port)
- })
-}
-
-func TestRedisOptions_Struct(t *testing.T) {
- t.Run("all fields accessible", func(t *testing.T) {
- opts := RedisOptions{
- Port: 6379,
- ConfigFile: "redis.conf",
- }
-
- assert.Equal(t, 6379, opts.Port)
- assert.Equal(t, "redis.conf", opts.ConfigFile)
- })
-}
-
-func TestBaseService_StopProcess_Good(t *testing.T) {
- t.Run("returns nil when not running", func(t *testing.T) {
- s := &baseService{running: false}
- err := s.stopProcess()
- assert.NoError(t, err)
- })
-
- t.Run("returns nil when cmd is nil", func(t *testing.T) {
- s := &baseService{running: true, cmd: nil}
- err := s.stopProcess()
- assert.NoError(t, err)
- })
-}
diff --git a/services_test.go b/services_test.go
deleted file mode 100644
index 5a0e66c..0000000
--- a/services_test.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package php
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestNewFrankenPHPService_Good(t *testing.T) {
- t.Run("default options", func(t *testing.T) {
- dir := "/tmp/test"
- service := NewFrankenPHPService(dir, FrankenPHPOptions{})
-
- assert.Equal(t, "FrankenPHP", service.Name())
- assert.Equal(t, 8000, service.port)
- assert.Equal(t, 443, service.httpsPort)
- assert.False(t, service.https)
- })
-
- t.Run("custom options", func(t *testing.T) {
- dir := "/tmp/test"
- opts := FrankenPHPOptions{
- Port: 9000,
- HTTPSPort: 8443,
- HTTPS: true,
- CertFile: "cert.pem",
- KeyFile: "key.pem",
- }
- service := NewFrankenPHPService(dir, opts)
-
- assert.Equal(t, 9000, service.port)
- assert.Equal(t, 8443, service.httpsPort)
- assert.True(t, service.https)
- assert.Equal(t, "cert.pem", service.certFile)
- assert.Equal(t, "key.pem", service.keyFile)
- })
-}
-
-func TestNewViteService_Good(t *testing.T) {
- t.Run("default options", func(t *testing.T) {
- dir := t.TempDir()
- service := NewViteService(dir, ViteOptions{})
-
- assert.Equal(t, "Vite", service.Name())
- assert.Equal(t, 5173, service.port)
- assert.Equal(t, "npm", service.packageManager) // default when no lock file
- })
-
- t.Run("custom package manager", func(t *testing.T) {
- dir := t.TempDir()
- service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
-
- assert.Equal(t, "pnpm", service.packageManager)
- })
-}
-
-func TestNewHorizonService_Good(t *testing.T) {
- service := NewHorizonService("/tmp/test")
- assert.Equal(t, "Horizon", service.Name())
- assert.Equal(t, 0, service.port)
-}
-
-func TestNewReverbService_Good(t *testing.T) {
- t.Run("default options", func(t *testing.T) {
- service := NewReverbService("/tmp/test", ReverbOptions{})
- assert.Equal(t, "Reverb", service.Name())
- assert.Equal(t, 8080, service.port)
- })
-
- t.Run("custom port", func(t *testing.T) {
- service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090})
- assert.Equal(t, 9090, service.port)
- })
-}
-
-func TestNewRedisService_Good(t *testing.T) {
- t.Run("default options", func(t *testing.T) {
- service := NewRedisService("/tmp/test", RedisOptions{})
- assert.Equal(t, "Redis", service.Name())
- assert.Equal(t, 6379, service.port)
- })
-
- t.Run("custom config", func(t *testing.T) {
- service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"})
- assert.Equal(t, "redis.conf", service.configFile)
- })
-}
-
-func TestBaseService_Status(t *testing.T) {
- s := &baseService{
- name: "TestService",
- port: 1234,
- running: true,
- }
-
- status := s.Status()
- assert.Equal(t, "TestService", status.Name)
- assert.Equal(t, 1234, status.Port)
- assert.True(t, status.Running)
-}
diff --git a/services_unix.go b/services_unix.go
deleted file mode 100644
index b7eb31e..0000000
--- a/services_unix.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//go:build unix
-
-package php
-
-import (
- "os/exec"
- "syscall"
-)
-
-// setSysProcAttr sets Unix-specific process attributes for clean process group handling.
-func setSysProcAttr(cmd *exec.Cmd) {
- cmd.SysProcAttr = &syscall.SysProcAttr{
- Setpgid: true,
- }
-}
-
-// signalProcessGroup sends a signal to the process group.
-// On Unix, this uses negative PID to signal the entire group.
-func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error {
- if cmd.Process == nil {
- return nil
- }
-
- pgid, err := syscall.Getpgid(cmd.Process.Pid)
- if err == nil {
- return syscall.Kill(-pgid, sig)
- }
-
- // Fallback to signaling just the process
- return cmd.Process.Signal(sig)
-}
-
-// termSignal returns SIGTERM for Unix.
-func termSignal() syscall.Signal {
- return syscall.SIGTERM
-}
-
-// killSignal returns SIGKILL for Unix.
-func killSignal() syscall.Signal {
- return syscall.SIGKILL
-}
diff --git a/services_windows.go b/services_windows.go
deleted file mode 100644
index 3da98b9..0000000
--- a/services_windows.go
+++ /dev/null
@@ -1,34 +0,0 @@
-//go:build windows
-
-package php
-
-import (
- "os"
- "os/exec"
-)
-
-// setSysProcAttr sets Windows-specific process attributes.
-// Windows doesn't support Setpgid, so this is a no-op.
-func setSysProcAttr(cmd *exec.Cmd) {
- // No-op on Windows - process groups work differently
-}
-
-// signalProcessGroup sends a termination signal to the process.
-// On Windows, we can only signal the main process, not a group.
-func signalProcessGroup(cmd *exec.Cmd, sig os.Signal) error {
- if cmd.Process == nil {
- return nil
- }
-
- return cmd.Process.Signal(sig)
-}
-
-// termSignal returns os.Interrupt for Windows (closest to SIGTERM).
-func termSignal() os.Signal {
- return os.Interrupt
-}
-
-// killSignal returns os.Kill for Windows.
-func killSignal() os.Signal {
- return os.Kill
-}
diff --git a/src/Core/Actions/Action.php b/src/Core/Actions/Action.php
new file mode 100644
index 0000000..b8ccbec
--- /dev/null
+++ b/src/Core/Actions/Action.php
@@ -0,0 +1,52 @@
+createPage->handle($user, $data);
+ *
+ * // Via static helper
+ * $page = CreatePage::run($user, $data);
+ *
+ * // Via app container
+ * $page = app(CreatePage::class)->handle($user, $data);
+ *
+ * Directory structure:
+ * app/Mod/{Module}/Actions/
+ * ├── CreateThing.php
+ * ├── UpdateThing.php
+ * ├── DeleteThing.php
+ * └── Thing/
+ * ├── PublishThing.php
+ * └── ArchiveThing.php
+ */
+trait Action
+{
+ /**
+ * Run the action via the container.
+ *
+ * Resolves the action from the container (with dependencies)
+ * and calls handle() with the provided arguments.
+ */
+ public static function run(mixed ...$args): mixed
+ {
+ return app(static::class)->handle(...$args);
+ }
+}
diff --git a/src/Core/Actions/Actionable.php b/src/Core/Actions/Actionable.php
new file mode 100644
index 0000000..cf09e72
--- /dev/null
+++ b/src/Core/Actions/Actionable.php
@@ -0,0 +1,19 @@
+ 'onConsole',
+ AdminPanelBooting::class => 'onAdmin',
+ ];
+
+ /**
+ * Register console commands.
+ */
+ public function onConsole(ConsoleBooting $event): void
+ {
+ if (! $this->isEnabled()) {
+ return;
+ }
+
+ $event->command(ActivityPruneCommand::class);
+ }
+
+ /**
+ * Register admin panel components and routes.
+ */
+ public function onAdmin(AdminPanelBooting $event): void
+ {
+ if (! $this->isEnabled()) {
+ return;
+ }
+
+ // Register view namespace
+ $event->views('core.activity', __DIR__.'/View/Blade');
+
+ // Register Livewire component (only if Livewire is available)
+ if (app()->bound('livewire')) {
+ Livewire::component('core.activity-feed', ActivityFeed::class);
+ }
+
+ // Bind service as singleton
+ app()->singleton(ActivityLogService::class);
+ }
+
+ /**
+ * Check if activity logging is enabled.
+ */
+ protected function isEnabled(): bool
+ {
+ return config('core.activity.enabled', true);
+ }
+}
diff --git a/src/Core/Activity/Concerns/LogsActivity.php b/src/Core/Activity/Concerns/LogsActivity.php
new file mode 100644
index 0000000..18e9896
--- /dev/null
+++ b/src/Core/Activity/Concerns/LogsActivity.php
@@ -0,0 +1,228 @@
+shouldLogOnlyDirty()) {
+ $options->logOnlyDirty();
+ }
+
+ // Only log if there are actual changes
+ $options->dontSubmitEmptyLogs();
+
+ // Set log name from model property or config
+ $options->useLogName($this->getActivityLogName());
+
+ // Configure which attributes to log
+ $attributes = $this->getActivityLogAttributes();
+ if ($attributes !== null) {
+ $options->logOnly($attributes);
+ } else {
+ $options->logAll();
+ }
+
+ // Configure which events to log
+ $events = $this->getActivityLogEvents();
+ $options->logOnlyDirty();
+
+ // Set custom description generator
+ $options->setDescriptionForEvent(fn (string $eventName) => $this->getActivityDescription($eventName));
+
+ return $options;
+ }
+
+ /**
+ * Tap into the activity before it's saved to add workspace_id.
+ */
+ public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
+ {
+ if ($this->shouldIncludeWorkspace()) {
+ $workspaceId = $this->getActivityWorkspaceId();
+ if ($workspaceId !== null) {
+ $activity->properties = $activity->properties->merge([
+ 'workspace_id' => $workspaceId,
+ ]);
+ }
+ }
+
+ // Allow further customisation in using models
+ if (method_exists($this, 'customizeActivity')) {
+ $this->customizeActivity($activity, $eventName);
+ }
+ }
+
+ /**
+ * Get the workspace ID for this activity.
+ */
+ protected function getActivityWorkspaceId(): ?int
+ {
+ // If model has workspace_id attribute, use it
+ if (isset($this->workspace_id)) {
+ return $this->workspace_id;
+ }
+
+ // Try to get from current workspace context
+ return $this->getCurrentWorkspaceId();
+ }
+
+ /**
+ * Get the current workspace ID from context.
+ */
+ protected function getCurrentWorkspaceId(): ?int
+ {
+ // First try to get from request attributes (set by middleware)
+ if (request()->attributes->has('workspace_model')) {
+ $workspace = request()->attributes->get('workspace_model');
+
+ return $workspace?->id;
+ }
+
+ // Then try to get from authenticated user
+ $user = auth()->user();
+ if ($user && method_exists($user, 'defaultHostWorkspace')) {
+ $workspace = $user->defaultHostWorkspace();
+
+ return $workspace?->id;
+ }
+
+ return null;
+ }
+
+ /**
+ * Generate a description for the activity event.
+ */
+ protected function getActivityDescription(string $eventName): string
+ {
+ $modelName = class_basename(static::class);
+
+ return match ($eventName) {
+ 'created' => "Created {$modelName}",
+ 'updated' => "Updated {$modelName}",
+ 'deleted' => "Deleted {$modelName}",
+ default => ucfirst($eventName)." {$modelName}",
+ };
+ }
+
+ /**
+ * Get the log name for this model.
+ */
+ protected function getActivityLogName(): string
+ {
+ if (property_exists($this, 'activityLogName') && $this->activityLogName) {
+ return $this->activityLogName;
+ }
+
+ return config('core.activity.log_name', 'default');
+ }
+
+ /**
+ * Get the attributes to log.
+ *
+ * @return array|null Null means log all attributes
+ */
+ protected function getActivityLogAttributes(): ?array
+ {
+ if (property_exists($this, 'activityLogAttributes') && is_array($this->activityLogAttributes)) {
+ return $this->activityLogAttributes;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the events to log.
+ *
+ * @return array
+ */
+ protected function getActivityLogEvents(): array
+ {
+ if (property_exists($this, 'activityLogEvents') && is_array($this->activityLogEvents)) {
+ return $this->activityLogEvents;
+ }
+
+ return config('core.activity.default_events', ['created', 'updated', 'deleted']);
+ }
+
+ /**
+ * Whether to include workspace_id in activity properties.
+ */
+ protected function shouldIncludeWorkspace(): bool
+ {
+ if (property_exists($this, 'activityLogWorkspace')) {
+ return (bool) $this->activityLogWorkspace;
+ }
+
+ return config('core.activity.include_workspace', true);
+ }
+
+ /**
+ * Whether to only log dirty (changed) attributes.
+ */
+ protected function shouldLogOnlyDirty(): bool
+ {
+ if (property_exists($this, 'activityLogOnlyDirty')) {
+ return (bool) $this->activityLogOnlyDirty;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if activity logging is enabled.
+ */
+ public static function activityLoggingEnabled(): bool
+ {
+ return config('core.activity.enabled', true);
+ }
+
+ /**
+ * Temporarily disable activity logging for a callback.
+ */
+ public static function withoutActivityLogging(callable $callback): mixed
+ {
+ return activity()->withoutLogs($callback);
+ }
+}
diff --git a/src/Core/Activity/Console/ActivityPruneCommand.php b/src/Core/Activity/Console/ActivityPruneCommand.php
new file mode 100644
index 0000000..4d7bd53
--- /dev/null
+++ b/src/Core/Activity/Console/ActivityPruneCommand.php
@@ -0,0 +1,65 @@
+option('days')
+ ? (int) $this->option('days')
+ : config('core.activity.retention_days', 90);
+
+ if ($days <= 0) {
+ $this->warn('Activity pruning is disabled (retention_days = 0).');
+
+ return self::SUCCESS;
+ }
+
+ $cutoffDate = now()->subDays($days);
+
+ $this->info("Pruning activities older than {$days} days (before {$cutoffDate->toDateString()})...");
+
+ if ($this->option('dry-run')) {
+ // Count without deleting
+ $activityModel = config('core.activity.activity_model', \Spatie\Activitylog\Models\Activity::class);
+ $count = $activityModel::where('created_at', '<', $cutoffDate)->count();
+
+ $this->info("Would delete {$count} activity records.");
+
+ return self::SUCCESS;
+ }
+
+ $deleted = $activityService->prune($days);
+
+ $this->info("Deleted {$deleted} old activity records.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Core/Activity/Models/Activity.php b/src/Core/Activity/Models/Activity.php
new file mode 100644
index 0000000..b95a408
--- /dev/null
+++ b/src/Core/Activity/Models/Activity.php
@@ -0,0 +1,203 @@
+ \Core\Activity\Models\Activity::class,
+ *
+ * @method static \Illuminate\Database\Eloquent\Builder forWorkspace(\Illuminate\Database\Eloquent\Model|int $workspace)
+ * @method static \Illuminate\Database\Eloquent\Builder forSubject(\Illuminate\Database\Eloquent\Model $subject)
+ * @method static \Illuminate\Database\Eloquent\Builder forSubjectType(string $subjectType)
+ * @method static \Illuminate\Database\Eloquent\Builder byCauser(\Illuminate\Contracts\Auth\Authenticatable|\Illuminate\Database\Eloquent\Model $user)
+ * @method static \Illuminate\Database\Eloquent\Builder byCauserId(int $causerId, string|null $causerType = null)
+ * @method static \Illuminate\Database\Eloquent\Builder ofType(string|array $event)
+ * @method static \Illuminate\Database\Eloquent\Builder createdEvents()
+ * @method static \Illuminate\Database\Eloquent\Builder updatedEvents()
+ * @method static \Illuminate\Database\Eloquent\Builder deletedEvents()
+ * @method static \Illuminate\Database\Eloquent\Builder betweenDates(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null)
+ * @method static \Illuminate\Database\Eloquent\Builder today()
+ * @method static \Illuminate\Database\Eloquent\Builder lastDays(int $days)
+ * @method static \Illuminate\Database\Eloquent\Builder lastHours(int $hours)
+ * @method static \Illuminate\Database\Eloquent\Builder search(string $search)
+ * @method static \Illuminate\Database\Eloquent\Builder inLog(string $logName)
+ * @method static \Illuminate\Database\Eloquent\Builder withChanges()
+ * @method static \Illuminate\Database\Eloquent\Builder withExistingSubject()
+ * @method static \Illuminate\Database\Eloquent\Builder withDeletedSubject()
+ * @method static \Illuminate\Database\Eloquent\Builder newest()
+ * @method static \Illuminate\Database\Eloquent\Builder oldest()
+ */
+class Activity extends SpatieActivity
+{
+ use ActivityScopes;
+
+ /**
+ * Get the workspace ID from properties.
+ */
+ public function getWorkspaceIdAttribute(): ?int
+ {
+ return $this->properties->get('workspace_id');
+ }
+
+ /**
+ * Get the old values from properties.
+ *
+ * @return array
+ */
+ public function getOldValuesAttribute(): array
+ {
+ return $this->properties->get('old', []);
+ }
+
+ /**
+ * Get the new values from properties.
+ *
+ * @return array
+ */
+ public function getNewValuesAttribute(): array
+ {
+ return $this->properties->get('attributes', []);
+ }
+
+ /**
+ * Get the changed attributes.
+ *
+ * @return array
+ */
+ public function getChangesAttribute(): array
+ {
+ $old = $this->old_values;
+ $new = $this->new_values;
+ $changes = [];
+
+ foreach ($new as $key => $newValue) {
+ $oldValue = $old[$key] ?? null;
+ if ($oldValue !== $newValue) {
+ $changes[$key] = [
+ 'old' => $oldValue,
+ 'new' => $newValue,
+ ];
+ }
+ }
+
+ return $changes;
+ }
+
+ /**
+ * Check if this activity has any changes.
+ */
+ public function hasChanges(): bool
+ {
+ return ! empty($this->new_values) || ! empty($this->old_values);
+ }
+
+ /**
+ * Get a human-readable summary of changes.
+ */
+ public function getChangesSummary(): string
+ {
+ $changes = $this->changes;
+
+ if (empty($changes)) {
+ return 'No changes recorded';
+ }
+
+ $parts = [];
+ foreach ($changes as $field => $values) {
+ $parts[] = sprintf(
+ '%s: %s -> %s',
+ $field,
+ $this->formatValue($values['old']),
+ $this->formatValue($values['new'])
+ );
+ }
+
+ return implode(', ', $parts);
+ }
+
+ /**
+ * Format a value for display.
+ */
+ protected function formatValue(mixed $value): string
+ {
+ if ($value === null) {
+ return 'null';
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+
+ if (is_array($value)) {
+ return json_encode($value);
+ }
+
+ if ($value instanceof \DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * Get the display name for the causer.
+ */
+ public function getCauserNameAttribute(): string
+ {
+ $causer = $this->causer;
+
+ if (! $causer) {
+ return 'System';
+ }
+
+ return $causer->name ?? $causer->email ?? 'User #'.$causer->getKey();
+ }
+
+ /**
+ * Get the display name for the subject.
+ */
+ public function getSubjectNameAttribute(): ?string
+ {
+ $subject = $this->subject;
+
+ if (! $subject) {
+ return null;
+ }
+
+ // Try common name attributes
+ foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) {
+ if (isset($subject->{$attribute})) {
+ return (string) $subject->{$attribute};
+ }
+ }
+
+ return class_basename($subject).' #'.$subject->getKey();
+ }
+
+ /**
+ * Get the subject type as a readable name.
+ */
+ public function getSubjectTypeNameAttribute(): ?string
+ {
+ return $this->subject_type ? class_basename($this->subject_type) : null;
+ }
+}
diff --git a/src/Core/Activity/Scopes/ActivityScopes.php b/src/Core/Activity/Scopes/ActivityScopes.php
new file mode 100644
index 0000000..64ca4f9
--- /dev/null
+++ b/src/Core/Activity/Scopes/ActivityScopes.php
@@ -0,0 +1,262 @@
+get();
+ * Activity::forSubject($post)->ofType('updated')->get();
+ *
+ * @requires spatie/laravel-activitylog
+ */
+trait ActivityScopes
+{
+ /**
+ * Scope activities to a specific workspace.
+ *
+ * Filters activities where either:
+ * - The workspace_id is stored in properties
+ * - The subject model has the given workspace_id
+ *
+ * @param Model|int $workspace Workspace model or ID
+ */
+ public function scopeForWorkspace(Builder $query, Model|int $workspace): Builder
+ {
+ $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace;
+
+ return $query->where(function (Builder $q) use ($workspaceId) {
+ // Check properties->workspace_id
+ $q->whereJsonContains('properties->workspace_id', $workspaceId);
+
+ // Or check if subject has workspace_id
+ $q->orWhereHasMorph(
+ 'subject',
+ '*',
+ fn (Builder $subjectQuery) => $subjectQuery->where('workspace_id', $workspaceId)
+ );
+ });
+ }
+
+ /**
+ * Scope activities to a specific subject model.
+ *
+ * @param Model $subject The subject model instance
+ */
+ public function scopeForSubject(Builder $query, Model $subject): Builder
+ {
+ return $query
+ ->where('subject_type', get_class($subject))
+ ->where('subject_id', $subject->getKey());
+ }
+
+ /**
+ * Scope activities to a specific subject type.
+ *
+ * @param string $subjectType Fully qualified class name
+ */
+ public function scopeForSubjectType(Builder $query, string $subjectType): Builder
+ {
+ return $query->where('subject_type', $subjectType);
+ }
+
+ /**
+ * Scope activities by the causer (user who performed the action).
+ *
+ * @param Authenticatable|Model $user The causer model
+ */
+ public function scopeByCauser(Builder $query, Authenticatable|Model $user): Builder
+ {
+ return $query
+ ->where('causer_type', get_class($user))
+ ->where('causer_id', $user->getKey());
+ }
+
+ /**
+ * Scope activities by causer ID (when you don't have the model).
+ *
+ * @param int $causerId The causer's primary key
+ * @param string|null $causerType Optional causer type (defaults to User model)
+ */
+ public function scopeByCauserId(Builder $query, int $causerId, ?string $causerType = null): Builder
+ {
+ $query->where('causer_id', $causerId);
+
+ if ($causerType !== null) {
+ $query->where('causer_type', $causerType);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Scope activities by event type.
+ *
+ * @param string|array $event Event type(s): 'created', 'updated', 'deleted'
+ */
+ public function scopeOfType(Builder $query, string|array $event): Builder
+ {
+ $events = is_array($event) ? $event : [$event];
+
+ return $query->whereIn('event', $events);
+ }
+
+ /**
+ * Scope to only created events.
+ */
+ public function scopeCreatedEvents(Builder $query): Builder
+ {
+ return $query->where('event', 'created');
+ }
+
+ /**
+ * Scope to only updated events.
+ */
+ public function scopeUpdatedEvents(Builder $query): Builder
+ {
+ return $query->where('event', 'updated');
+ }
+
+ /**
+ * Scope to only deleted events.
+ */
+ public function scopeDeletedEvents(Builder $query): Builder
+ {
+ return $query->where('event', 'deleted');
+ }
+
+ /**
+ * Scope activities within a date range.
+ *
+ * @param \DateTimeInterface|string $from Start date
+ * @param \DateTimeInterface|string|null $to End date (optional)
+ */
+ public function scopeBetweenDates(Builder $query, \DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): Builder
+ {
+ $query->where('created_at', '>=', $from);
+
+ if ($to !== null) {
+ $query->where('created_at', '<=', $to);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Scope activities from today.
+ */
+ public function scopeToday(Builder $query): Builder
+ {
+ return $query->whereDate('created_at', now()->toDateString());
+ }
+
+ /**
+ * Scope activities from the last N days.
+ *
+ * @param int $days Number of days
+ */
+ public function scopeLastDays(Builder $query, int $days): Builder
+ {
+ return $query->where('created_at', '>=', now()->subDays($days));
+ }
+
+ /**
+ * Scope activities from the last N hours.
+ *
+ * @param int $hours Number of hours
+ */
+ public function scopeLastHours(Builder $query, int $hours): Builder
+ {
+ return $query->where('created_at', '>=', now()->subHours($hours));
+ }
+
+ /**
+ * Search activities by description.
+ *
+ * @param string $search Search term
+ */
+ public function scopeSearch(Builder $query, string $search): Builder
+ {
+ $term = '%'.addcslashes($search, '%_').'%';
+
+ return $query->where(function (Builder $q) use ($term) {
+ $q->where('description', 'LIKE', $term)
+ ->orWhere('properties', 'LIKE', $term);
+ });
+ }
+
+ /**
+ * Scope to activities in a specific log.
+ *
+ * @param string $logName The log name
+ */
+ public function scopeInLog(Builder $query, string $logName): Builder
+ {
+ return $query->where('log_name', $logName);
+ }
+
+ /**
+ * Scope to activities with changes (non-empty properties).
+ */
+ public function scopeWithChanges(Builder $query): Builder
+ {
+ return $query->where(function (Builder $q) {
+ $q->whereJsonLength('properties->attributes', '>', 0)
+ ->orWhereJsonLength('properties->old', '>', 0);
+ });
+ }
+
+ /**
+ * Scope to activities for models that still exist.
+ */
+ public function scopeWithExistingSubject(Builder $query): Builder
+ {
+ return $query->whereHas('subject');
+ }
+
+ /**
+ * Scope to activities for models that have been deleted.
+ */
+ public function scopeWithDeletedSubject(Builder $query): Builder
+ {
+ return $query->whereDoesntHave('subject');
+ }
+
+ /**
+ * Order by newest first.
+ */
+ public function scopeNewest(Builder $query): Builder
+ {
+ return $query->latest('created_at');
+ }
+
+ /**
+ * Order by oldest first.
+ */
+ public function scopeOldest(Builder $query): Builder
+ {
+ return $query->oldest('created_at');
+ }
+}
diff --git a/src/Core/Activity/Services/ActivityLogService.php b/src/Core/Activity/Services/ActivityLogService.php
new file mode 100644
index 0000000..a39a297
--- /dev/null
+++ b/src/Core/Activity/Services/ActivityLogService.php
@@ -0,0 +1,448 @@
+logFor($post);
+ *
+ * // Get activities by a user within a workspace
+ * $activities = $service->logBy($user)->forWorkspace($workspace)->recent();
+ *
+ * // Search activities
+ * $results = $service->search('updated post');
+ *
+ * @requires spatie/laravel-activitylog
+ */
+class ActivityLogService
+{
+ protected ?Builder $query = null;
+
+ protected ?int $workspaceId = null;
+
+ /**
+ * Get the base activity query.
+ */
+ protected function newQuery(): Builder
+ {
+ return Activity::query()->latest();
+ }
+
+ /**
+ * Get or create the current query builder.
+ */
+ protected function query(): Builder
+ {
+ if ($this->query === null) {
+ $this->query = $this->newQuery();
+ }
+
+ return $this->query;
+ }
+
+ /**
+ * Reset the query builder for a new chain.
+ */
+ public function fresh(): self
+ {
+ $this->query = null;
+ $this->workspaceId = null;
+
+ return $this;
+ }
+
+ /**
+ * Get activities for a specific model (subject).
+ *
+ * @param Model $subject The model to get activities for
+ */
+ public function logFor(Model $subject): self
+ {
+ $this->query()
+ ->where('subject_type', get_class($subject))
+ ->where('subject_id', $subject->getKey());
+
+ return $this;
+ }
+
+ /**
+ * Get activities performed by a specific user.
+ *
+ * @param Authenticatable|Model $causer The user who caused the activities
+ */
+ public function logBy(Authenticatable|Model $causer): self
+ {
+ $this->query()
+ ->where('causer_type', get_class($causer))
+ ->where('causer_id', $causer->getKey());
+
+ return $this;
+ }
+
+ /**
+ * Scope activities to a specific workspace.
+ *
+ * @param Model|int $workspace The workspace or workspace ID
+ */
+ public function forWorkspace(Model|int $workspace): self
+ {
+ $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace;
+ $this->workspaceId = $workspaceId;
+
+ $this->query()->where(function (Builder $q) use ($workspaceId) {
+ $q->whereJsonContains('properties->workspace_id', $workspaceId)
+ ->orWhere(function (Builder $subQ) use ($workspaceId) {
+ // Also check if subject has workspace_id
+ $subQ->whereHas('subject', function (Builder $subjectQuery) use ($workspaceId) {
+ $subjectQuery->where('workspace_id', $workspaceId);
+ });
+ });
+ });
+
+ return $this;
+ }
+
+ /**
+ * Filter activities by subject type.
+ *
+ * @param string $subjectType Fully qualified class name
+ */
+ public function forSubjectType(string $subjectType): self
+ {
+ $this->query()->where('subject_type', $subjectType);
+
+ return $this;
+ }
+
+ /**
+ * Filter activities by event type.
+ *
+ * @param string|array $event Event type(s): 'created', 'updated', 'deleted', etc.
+ */
+ public function ofType(string|array $event): self
+ {
+ $events = is_array($event) ? $event : [$event];
+
+ $this->query()->whereIn('event', $events);
+
+ return $this;
+ }
+
+ /**
+ * Filter activities by log name.
+ *
+ * @param string $logName The log name to filter by
+ */
+ public function inLog(string $logName): self
+ {
+ $this->query()->where('log_name', $logName);
+
+ return $this;
+ }
+
+ /**
+ * Filter activities within a date range.
+ */
+ public function between(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): self
+ {
+ $this->query()->where('created_at', '>=', $from);
+
+ if ($to !== null) {
+ $this->query()->where('created_at', '<=', $to);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Filter activities from the last N days.
+ *
+ * @param int $days Number of days
+ */
+ public function lastDays(int $days): self
+ {
+ $this->query()->where('created_at', '>=', now()->subDays($days));
+
+ return $this;
+ }
+
+ /**
+ * Search activity descriptions.
+ *
+ * @param string $query Search query
+ */
+ public function search(string $query): self
+ {
+ $searchTerm = '%'.addcslashes($query, '%_').'%';
+
+ $this->query()->where(function (Builder $q) use ($searchTerm) {
+ $q->where('description', 'LIKE', $searchTerm)
+ ->orWhere('properties', 'LIKE', $searchTerm);
+ });
+
+ return $this;
+ }
+
+ /**
+ * Get recent activities with optional limit.
+ *
+ * @param int $limit Maximum number of activities to return
+ */
+ public function recent(int $limit = 50): Collection
+ {
+ return $this->query()
+ ->with(['causer', 'subject'])
+ ->limit($limit)
+ ->get();
+ }
+
+ /**
+ * Get paginated activities.
+ *
+ * @param int $perPage Number of activities per page
+ */
+ public function paginate(int $perPage = 15): LengthAwarePaginator
+ {
+ return $this->query()
+ ->with(['causer', 'subject'])
+ ->paginate($perPage);
+ }
+
+ /**
+ * Get all filtered activities.
+ */
+ public function get(): Collection
+ {
+ return $this->query()
+ ->with(['causer', 'subject'])
+ ->get();
+ }
+
+ /**
+ * Get the first activity.
+ */
+ public function first(): ?Activity
+ {
+ return $this->query()
+ ->with(['causer', 'subject'])
+ ->first();
+ }
+
+ /**
+ * Count the activities.
+ */
+ public function count(): int
+ {
+ return $this->query()->count();
+ }
+
+ /**
+ * Get activity statistics for a workspace.
+ *
+ * @return array{total: int, by_event: array, by_subject: array, by_user: array}
+ */
+ public function statistics(Model|int|null $workspace = null): array
+ {
+ $query = $this->newQuery();
+
+ if ($workspace !== null) {
+ $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace;
+ $query->whereJsonContains('properties->workspace_id', $workspaceId);
+ }
+
+ // Get totals by event type
+ $byEvent = (clone $query)
+ ->selectRaw('event, COUNT(*) as count')
+ ->groupBy('event')
+ ->pluck('count', 'event')
+ ->toArray();
+
+ // Get totals by subject type
+ $bySubject = (clone $query)
+ ->selectRaw('subject_type, COUNT(*) as count')
+ ->whereNotNull('subject_type')
+ ->groupBy('subject_type')
+ ->pluck('count', 'subject_type')
+ ->mapWithKeys(fn ($count, $type) => [class_basename($type) => $count])
+ ->toArray();
+
+ // Get top users
+ $byUser = (clone $query)
+ ->selectRaw('causer_id, causer_type, COUNT(*) as count')
+ ->whereNotNull('causer_id')
+ ->groupBy('causer_id', 'causer_type')
+ ->orderByDesc('count')
+ ->limit(10)
+ ->get()
+ ->mapWithKeys(function ($row) {
+ $causer = $row->causer;
+ $name = $causer?->name ?? $causer?->email ?? "User #{$row->causer_id}";
+
+ return [$name => $row->count];
+ })
+ ->toArray();
+
+ return [
+ 'total' => $query->count(),
+ 'by_event' => $byEvent,
+ 'by_subject' => $bySubject,
+ 'by_user' => $byUser,
+ ];
+ }
+
+ /**
+ * Get timeline of activities grouped by date.
+ *
+ * @param int $days Number of days to include
+ */
+ public function timeline(int $days = 30): \Illuminate\Support\Collection
+ {
+ return $this->lastDays($days)
+ ->query()
+ ->selectRaw('DATE(created_at) as date, COUNT(*) as count')
+ ->groupBy('date')
+ ->orderBy('date')
+ ->pluck('count', 'date');
+ }
+
+ /**
+ * Format an activity for display.
+ *
+ * @return array{
+ * id: int,
+ * event: string,
+ * description: string,
+ * timestamp: string,
+ * relative_time: string,
+ * actor: array|null,
+ * subject: array|null,
+ * changes: array|null,
+ * workspace_id: int|null
+ * }
+ */
+ public function format(Activity $activity): array
+ {
+ $causer = $activity->causer;
+ $subject = $activity->subject;
+ $properties = $activity->properties;
+
+ // Extract changes if available
+ $changes = null;
+ if ($properties->has('attributes') || $properties->has('old')) {
+ $changes = [
+ 'old' => $properties->get('old', []),
+ 'new' => $properties->get('attributes', []),
+ ];
+ }
+
+ return [
+ 'id' => $activity->id,
+ 'event' => $activity->event ?? 'activity',
+ 'description' => $activity->description,
+ 'timestamp' => $activity->created_at->toIso8601String(),
+ 'relative_time' => $activity->created_at->diffForHumans(),
+ 'actor' => $causer ? [
+ 'id' => $causer->getKey(),
+ 'name' => $causer->name ?? $causer->email ?? 'Unknown',
+ 'avatar' => method_exists($causer, 'avatarUrl') ? $causer->avatarUrl() : null,
+ 'initials' => $this->getInitials($causer->name ?? $causer->email ?? 'U'),
+ ] : null,
+ 'subject' => $subject ? [
+ 'id' => $subject->getKey(),
+ 'type' => class_basename($subject),
+ 'name' => $this->getSubjectName($subject),
+ 'url' => $this->getSubjectUrl($subject),
+ ] : null,
+ 'changes' => $changes,
+ 'workspace_id' => $properties->get('workspace_id'),
+ ];
+ }
+
+ /**
+ * Get initials from a name.
+ */
+ protected function getInitials(string $name): string
+ {
+ $words = explode(' ', trim($name));
+
+ if (count($words) >= 2) {
+ return strtoupper(substr($words[0], 0, 1).substr(end($words), 0, 1));
+ }
+
+ return strtoupper(substr($name, 0, 2));
+ }
+
+ /**
+ * Get the display name for a subject.
+ */
+ protected function getSubjectName(Model $subject): string
+ {
+ // Try common name attributes
+ foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) {
+ if (isset($subject->{$attribute})) {
+ return (string) $subject->{$attribute};
+ }
+ }
+
+ return class_basename($subject).' #'.$subject->getKey();
+ }
+
+ /**
+ * Get the URL for a subject if available.
+ */
+ protected function getSubjectUrl(Model $subject): ?string
+ {
+ // If model has a getUrl method, use it
+ if (method_exists($subject, 'getUrl')) {
+ return $subject->getUrl();
+ }
+
+ // If model has a url attribute
+ if (isset($subject->url)) {
+ return $subject->url;
+ }
+
+ return null;
+ }
+
+ /**
+ * Delete activities older than the retention period.
+ *
+ * @param int|null $days Days to retain (null = use config)
+ * @return int Number of deleted activities
+ */
+ public function prune(?int $days = null): int
+ {
+ $retentionDays = $days ?? config('core.activity.retention_days', 90);
+
+ if ($retentionDays <= 0) {
+ return 0;
+ }
+
+ $cutoffDate = now()->subDays($retentionDays);
+
+ return Activity::where('created_at', '<', $cutoffDate)->delete();
+ }
+}
diff --git a/src/Core/Activity/View/Blade/admin/activity-feed.blade.php b/src/Core/Activity/View/Blade/admin/activity-feed.blade.php
new file mode 100644
index 0000000..d393a06
--- /dev/null
+++ b/src/Core/Activity/View/Blade/admin/activity-feed.blade.php
@@ -0,0 +1,322 @@
+ 0) wire:poll.{{ $pollInterval }}s @endif>
+
Activity Log
+
+ {{-- Statistics Cards --}}
+
+
+ Total Activities
+ {{ number_format($this->statistics['total']) }}
+
+
+
+ Created
+ {{ number_format($this->statistics['by_event']['created'] ?? 0) }}
+
+
+
+ Updated
+ {{ number_format($this->statistics['by_event']['updated'] ?? 0) }}
+
+
+
+ Deleted
+ {{ number_format($this->statistics['by_event']['deleted'] ?? 0) }}
+
+
+
+ {{-- Filters --}}
+
+
+
+ @foreach ($this->causers as $id => $name)
+ {{ $name }}
+ @endforeach
+
+
+
+ @foreach ($this->subjectTypes as $type => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+ @foreach ($this->eventTypes as $type => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+ @foreach ($this->dateRanges as $days => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+ @if ($causerId || $subjectType || $eventType || $daysBack !== 30 || $search)
+
+ Clear Filters
+
+ @endif
+
+
+
+ {{-- Activity List --}}
+
+ @if ($this->activities->isEmpty())
+
+
+ No Activities Found
+
+ @if ($causerId || $subjectType || $eventType || $search)
+ Try adjusting your filters to see more results.
+ @else
+ Activity logging is enabled but no activities have been recorded yet.
+ @endif
+
+
+ @else
+
+ @foreach ($this->activities as $activity)
+ @php
+ $formatted = $this->formatActivity($activity);
+ @endphp
+
+ {{-- Avatar --}}
+
+ @if ($formatted['actor'])
+ @if ($formatted['actor']['avatar'])
+
+ @else
+
+ {{ $formatted['actor']['initials'] }}
+
+ @endif
+ @else
+
+
+
+ @endif
+
+
+ {{-- Details --}}
+
+
+
+ {{ $formatted['actor']['name'] ?? 'System' }}
+
+
+ {{ $formatted['description'] }}
+
+
+
+ @if ($formatted['subject'])
+
+ {{ $formatted['subject']['type'] }}:
+ @if ($formatted['subject']['url'])
+
+ {{ $formatted['subject']['name'] }}
+
+ @else
+ {{ $formatted['subject']['name'] }}
+ @endif
+
+ @endif
+
+ @if ($formatted['changes'])
+
+
+ @php $changeCount = 0; @endphp
+ @foreach ($formatted['changes']['new'] as $key => $newValue)
+ @if (($formatted['changes']['old'][$key] ?? null) !== $newValue && $changeCount < 3)
+ @if ($changeCount > 0)
+ |
+ @endif
+ {{ $key }}:
+ {{ is_array($formatted['changes']['old'][$key] ?? null) ? json_encode($formatted['changes']['old'][$key]) : ($formatted['changes']['old'][$key] ?? 'null') }}
+ →
+ {{ is_array($newValue) ? json_encode($newValue) : $newValue }}
+ @php $changeCount++; @endphp
+ @endif
+ @endforeach
+ @if (count(array_filter($formatted['changes']['new'], fn($v, $k) => ($formatted['changes']['old'][$k] ?? null) !== $v, ARRAY_FILTER_USE_BOTH)) > 3)
+ +{{ count($formatted['changes']['new']) - 3 }} more
+ @endif
+
+
+ @endif
+
+
+ {{ $formatted['relative_time'] }}
+
+
+
+ {{-- Event Badge --}}
+
+
+
+ {{ ucfirst($formatted['event']) }}
+
+
+
+ @endforeach
+
+
+ {{-- Pagination --}}
+ @if ($this->activities->hasPages())
+
+ {{ $this->activities->links() }}
+
+ @endif
+ @endif
+
+
+ {{-- Detail Modal --}}
+
+ @if ($this->selectedActivity)
+ @php
+ $selected = $this->formatActivity($this->selectedActivity);
+ @endphp
+
+
Activity Details
+
+ {{-- Activity Header --}}
+
+ @if ($selected['actor'])
+ @if ($selected['actor']['avatar'])
+
+ @else
+
+ {{ $selected['actor']['initials'] }}
+
+ @endif
+ @else
+
+
+
+ @endif
+
+
+
+
+ {{ $selected['actor']['name'] ?? 'System' }}
+
+
+ {{ ucfirst($selected['event']) }}
+
+
+
+ {{ $selected['description'] }}
+
+
+ {{ $selected['relative_time'] }} · {{ \Carbon\Carbon::parse($selected['timestamp'])->format('M j, Y \a\t g:i A') }}
+
+
+
+
+ {{-- Subject Info --}}
+ @if ($selected['subject'])
+
+ Subject
+
+
+ @endif
+
+ {{-- Changes Diff --}}
+ @if ($selected['changes'] && (count($selected['changes']['old']) > 0 || count($selected['changes']['new']) > 0))
+
+
Changes
+
+
+
+
+ Field
+ Old Value
+ New Value
+
+
+
+ @foreach ($selected['changes']['new'] as $key => $newValue)
+ @php
+ $oldValue = $selected['changes']['old'][$key] ?? null;
+ @endphp
+ @if ($oldValue !== $newValue)
+
+ {{ $key }}
+
+ @if (is_array($oldValue))
+ {{ json_encode($oldValue, JSON_PRETTY_PRINT) }}
+ @elseif ($oldValue === null)
+ null
+ @elseif (is_bool($oldValue))
+ {{ $oldValue ? 'true' : 'false' }}
+ @else
+ {{ $oldValue }}
+ @endif
+
+
+ @if (is_array($newValue))
+ {{ json_encode($newValue, JSON_PRETTY_PRINT) }}
+ @elseif ($newValue === null)
+ null
+ @elseif (is_bool($newValue))
+ {{ $newValue ? 'true' : 'false' }}
+ @else
+ {{ $newValue }}
+ @endif
+
+
+ @endif
+ @endforeach
+
+
+
+
+ @endif
+
+ {{-- Raw Properties --}}
+
+
+
+ Raw Properties
+
+
+ {{ json_encode($this->selectedActivity->properties, JSON_PRETTY_PRINT) }}
+
+
+
+
+ {{-- Actions --}}
+
+ Close
+
+
+ @endif
+
+
diff --git a/src/Core/Activity/View/Modal/Admin/ActivityFeed.php b/src/Core/Activity/View/Modal/Admin/ActivityFeed.php
new file mode 100644
index 0000000..b92f66c
--- /dev/null
+++ b/src/Core/Activity/View/Modal/Admin/ActivityFeed.php
@@ -0,0 +1,369 @@
+
+ *
+ *
+ */
+class ActivityFeed extends Component
+{
+ use WithPagination;
+
+ /**
+ * Filter by workspace ID.
+ */
+ public ?int $workspaceId = null;
+
+ /**
+ * Filter by causer (user) ID.
+ */
+ #[Url]
+ public ?int $causerId = null;
+
+ /**
+ * Filter by subject type (model class basename).
+ */
+ #[Url]
+ public string $subjectType = '';
+
+ /**
+ * Filter by event type.
+ */
+ #[Url]
+ public string $eventType = '';
+
+ /**
+ * Filter by date range (days back).
+ */
+ #[Url]
+ public int $daysBack = 30;
+
+ /**
+ * Search query.
+ */
+ #[Url]
+ public string $search = '';
+
+ /**
+ * Currently selected activity for detail view.
+ */
+ public ?int $selectedActivityId = null;
+
+ /**
+ * Whether to show the detail modal.
+ */
+ public bool $showDetailModal = false;
+
+ /**
+ * Polling interval in seconds (0 = disabled).
+ */
+ public int $pollInterval = 0;
+
+ /**
+ * Number of items per page.
+ */
+ public int $perPage = 15;
+
+ protected ActivityLogService $activityService;
+
+ public function boot(ActivityLogService $activityService): void
+ {
+ $this->activityService = $activityService;
+ }
+
+ public function mount(?int $workspaceId = null, int $pollInterval = 0, int $perPage = 15): void
+ {
+ $this->workspaceId = $workspaceId;
+ $this->pollInterval = $pollInterval;
+ $this->perPage = $perPage;
+ }
+
+ /**
+ * Get available subject types for filtering.
+ *
+ * @return array
+ */
+ #[Computed]
+ public function subjectTypes(): array
+ {
+ $types = Activity::query()
+ ->whereNotNull('subject_type')
+ ->distinct()
+ ->pluck('subject_type')
+ ->mapWithKeys(fn ($type) => [class_basename($type) => class_basename($type)])
+ ->toArray();
+
+ return ['' => 'All Types'] + $types;
+ }
+
+ /**
+ * Get available event types for filtering.
+ *
+ * @return array
+ */
+ #[Computed]
+ public function eventTypes(): array
+ {
+ return [
+ '' => 'All Events',
+ 'created' => 'Created',
+ 'updated' => 'Updated',
+ 'deleted' => 'Deleted',
+ ];
+ }
+
+ /**
+ * Get available users (causers) for filtering.
+ *
+ * @return array
+ */
+ #[Computed]
+ public function causers(): array
+ {
+ $causers = Activity::query()
+ ->whereNotNull('causer_id')
+ ->with('causer')
+ ->distinct()
+ ->get()
+ ->mapWithKeys(function ($activity) {
+ $causer = $activity->causer;
+ if (! $causer) {
+ return [];
+ }
+ $name = $causer->name ?? $causer->email ?? "User #{$causer->getKey()}";
+
+ return [$causer->getKey() => $name];
+ })
+ ->filter()
+ ->toArray();
+
+ return ['' => 'All Users'] + $causers;
+ }
+
+ /**
+ * Get date range options.
+ *
+ * @return array
+ */
+ #[Computed]
+ public function dateRanges(): array
+ {
+ return [
+ 1 => 'Last 24 hours',
+ 7 => 'Last 7 days',
+ 30 => 'Last 30 days',
+ 90 => 'Last 90 days',
+ 365 => 'Last year',
+ ];
+ }
+
+ /**
+ * Get paginated activities.
+ */
+ #[Computed]
+ public function activities(): LengthAwarePaginator
+ {
+ $service = $this->activityService->fresh();
+
+ // Apply workspace filter
+ if ($this->workspaceId) {
+ $service->forWorkspace($this->workspaceId);
+ }
+
+ // Apply causer filter
+ if ($this->causerId) {
+ // We need to work around the service's user expectation
+ $service->query()->where('causer_id', $this->causerId);
+ }
+
+ // Apply subject type filter
+ if ($this->subjectType) {
+ // Find the full class name that matches the basename
+ $fullType = Activity::query()
+ ->where('subject_type', 'LIKE', '%\\'.$this->subjectType)
+ ->orWhere('subject_type', $this->subjectType)
+ ->value('subject_type');
+
+ if ($fullType) {
+ $service->forSubjectType($fullType);
+ }
+ }
+
+ // Apply event type filter
+ if ($this->eventType) {
+ $service->ofType($this->eventType);
+ }
+
+ // Apply date range
+ $service->lastDays($this->daysBack);
+
+ // Apply search
+ if ($this->search) {
+ $service->search($this->search);
+ }
+
+ return $service->paginate($this->perPage);
+ }
+
+ /**
+ * Get the selected activity for the detail modal.
+ */
+ #[Computed]
+ public function selectedActivity(): ?Activity
+ {
+ if (! $this->selectedActivityId) {
+ return null;
+ }
+
+ return Activity::with(['causer', 'subject'])->find($this->selectedActivityId);
+ }
+
+ /**
+ * Get activity statistics.
+ *
+ * @return array{total: int, by_event: array, by_subject: array}
+ */
+ #[Computed]
+ public function statistics(): array
+ {
+ return $this->activityService->statistics($this->workspaceId);
+ }
+
+ /**
+ * Show the detail modal for an activity.
+ */
+ public function showDetail(int $activityId): void
+ {
+ $this->selectedActivityId = $activityId;
+ $this->showDetailModal = true;
+ }
+
+ /**
+ * Close the detail modal.
+ */
+ public function closeDetail(): void
+ {
+ $this->showDetailModal = false;
+ $this->selectedActivityId = null;
+ }
+
+ /**
+ * Reset all filters.
+ */
+ public function resetFilters(): void
+ {
+ $this->causerId = null;
+ $this->subjectType = '';
+ $this->eventType = '';
+ $this->daysBack = 30;
+ $this->search = '';
+ $this->resetPage();
+ }
+
+ /**
+ * Handle filter changes by resetting pagination.
+ */
+ public function updatedCauserId(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedSubjectType(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedEventType(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedDaysBack(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ /**
+ * Format an activity for display.
+ *
+ * @return array{
+ * id: int,
+ * event: string,
+ * description: string,
+ * timestamp: string,
+ * relative_time: string,
+ * actor: array|null,
+ * subject: array|null,
+ * changes: array|null,
+ * workspace_id: int|null
+ * }
+ */
+ public function formatActivity(Activity $activity): array
+ {
+ return $this->activityService->format($activity);
+ }
+
+ /**
+ * Get the event color class.
+ */
+ public function eventColor(string $event): string
+ {
+ return match ($event) {
+ 'created' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
+ 'updated' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
+ 'deleted' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
+ default => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
+ };
+ }
+
+ /**
+ * Get the event icon.
+ */
+ public function eventIcon(string $event): string
+ {
+ return match ($event) {
+ 'created' => 'plus-circle',
+ 'updated' => 'pencil',
+ 'deleted' => 'trash',
+ default => 'clock',
+ };
+ }
+
+ public function render(): \Illuminate\Contracts\View\View
+ {
+ return view('core.activity::admin.activity-feed');
+ }
+}
diff --git a/src/Core/Boot.php b/src/Core/Boot.php
new file mode 100644
index 0000000..532ac14
--- /dev/null
+++ b/src/Core/Boot.php
@@ -0,0 +1,93 @@
+withProviders(self::$providers)
+ ->withMiddleware(function (Middleware $middleware): void {
+ // Session middleware priority
+ $middleware->priority([
+ \Illuminate\Session\Middleware\StartSession::class,
+ ]);
+
+ $middleware->redirectGuestsTo('/login');
+ $middleware->redirectUsersTo('/hub');
+
+ // Front module configures middleware groups (web, admin, api, mcp)
+ Front\Boot::middleware($middleware);
+ })
+ ->withExceptions(function (Exceptions $exceptions): void {
+ // Clean exception handling for open-source
+ // Apps can add Sentry, custom error pages, etc.
+ })->create();
+ }
+
+ /**
+ * Get the application base path.
+ *
+ * Works whether Core is in vendor/ or packages/ (monorepo).
+ */
+ protected static function basePath(): string
+ {
+ // Check for monorepo structure (packages/core-php/src/Core/Boot.php)
+ // The monorepo root has app/ directory while the package root doesn't
+ $monorepoBase = dirname(__DIR__, 4);
+ if (file_exists($monorepoBase.'/composer.json') && is_dir($monorepoBase.'/app')) {
+ return $monorepoBase;
+ }
+
+ // Standard vendor structure (vendor/*/core/src/Core/Boot.php)
+ return dirname(__DIR__, 5);
+ }
+}
diff --git a/src/Core/Bouncer/BlocklistService.php b/src/Core/Bouncer/BlocklistService.php
new file mode 100644
index 0000000..38c6801
--- /dev/null
+++ b/src/Core/Bouncer/BlocklistService.php
@@ -0,0 +1,347 @@
+call(function () {
+ * app(BlocklistService::class)->syncFromHoneypot();
+ * })->hourly();
+ * ```
+ *
+ * ### Reviewing Pending Blocks
+ *
+ * ```php
+ * $blocklist = app(BlocklistService::class);
+ *
+ * // Get all pending entries (paginated for large blocklists)
+ * $pending = $blocklist->getPending(perPage: 50);
+ *
+ * // Approve a block
+ * $blocklist->approve('192.168.1.100');
+ *
+ * // Reject a block (IP will not be blocked)
+ * $blocklist->reject('192.168.1.100');
+ * ```
+ *
+ * ## Cache Behaviour
+ *
+ * - Blocklist is cached for 5 minutes (CACHE_TTL constant)
+ * - Only 'approved' entries with valid expiry are included in cache
+ * - Cache is automatically cleared on block/unblock/approve operations
+ * - Use `clearCache()` to force cache refresh
+ *
+ * ## Manual Blocking
+ *
+ * ```php
+ * $blocklist = app(BlocklistService::class);
+ *
+ * // Block an IP immediately (approved status)
+ * $blocklist->block('192.168.1.100', 'spam', BlocklistService::STATUS_APPROVED);
+ *
+ * // Unblock an IP
+ * $blocklist->unblock('192.168.1.100');
+ *
+ * // Check if IP is blocked
+ * if ($blocklist->isBlocked('192.168.1.100')) {
+ * // IP is actively blocked
+ * }
+ * ```
+ *
+ * @see Boot For honeypot configuration options
+ * @see BouncerMiddleware For the blocking middleware
+ */
+class BlocklistService
+{
+ protected const CACHE_KEY = 'bouncer:blocklist';
+
+ protected const CACHE_TTL = 300; // 5 minutes
+
+ protected const DEFAULT_PER_PAGE = 50;
+
+ public const STATUS_PENDING = 'pending';
+
+ public const STATUS_APPROVED = 'approved';
+
+ public const STATUS_REJECTED = 'rejected';
+
+ /**
+ * Check if IP is blocked.
+ */
+ public function isBlocked(string $ip): bool
+ {
+ $blocklist = $this->getBlocklist();
+
+ return isset($blocklist[$ip]);
+ }
+
+ /**
+ * Add IP to blocklist (immediately approved for manual blocks).
+ */
+ public function block(string $ip, string $reason = 'manual', string $status = self::STATUS_APPROVED): void
+ {
+ DB::table('blocked_ips')->updateOrInsert(
+ ['ip_address' => $ip],
+ [
+ 'reason' => $reason,
+ 'status' => $status,
+ 'blocked_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ ]
+ );
+
+ $this->clearCache();
+ }
+
+ /**
+ * Remove IP from blocklist.
+ */
+ public function unblock(string $ip): void
+ {
+ DB::table('blocked_ips')->where('ip_address', $ip)->delete();
+ $this->clearCache();
+ }
+
+ /**
+ * Get full blocklist (cached). Only returns approved entries.
+ *
+ * Used for O(1) IP lookup checks. For admin UIs with large blocklists,
+ * use getBlocklistPaginated() instead.
+ */
+ public function getBlocklist(): array
+ {
+ return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
+ if (! $this->tableExists()) {
+ return [];
+ }
+
+ return DB::table('blocked_ips')
+ ->where('status', self::STATUS_APPROVED)
+ ->where(function ($query) {
+ $query->whereNull('expires_at')
+ ->orWhere('expires_at', '>', now());
+ })
+ ->pluck('reason', 'ip_address')
+ ->toArray();
+ });
+ }
+
+ /**
+ * Get paginated blocklist for admin UI.
+ *
+ * Returns all entries (approved, pending, rejected) with pagination.
+ * Use this for admin interfaces displaying large blocklists.
+ *
+ * @param int|null $perPage Number of entries per page (default: 50)
+ * @param string|null $status Filter by status (null for all statuses)
+ */
+ public function getBlocklistPaginated(?int $perPage = null, ?string $status = null): LengthAwarePaginator
+ {
+ if (! $this->tableExists()) {
+ return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?? self::DEFAULT_PER_PAGE);
+ }
+
+ $query = DB::table('blocked_ips')
+ ->orderBy('blocked_at', 'desc');
+
+ if ($status !== null) {
+ $query->where('status', $status);
+ }
+
+ return $query->paginate($perPage ?? self::DEFAULT_PER_PAGE);
+ }
+
+ /**
+ * Check if the blocked_ips table exists.
+ */
+ protected function tableExists(): bool
+ {
+ return Cache::remember('bouncer:blocked_ips_table_exists', 3600, function (): bool {
+ return DB::getSchemaBuilder()->hasTable('blocked_ips');
+ });
+ }
+
+ /**
+ * Sync blocklist from honeypot critical hits.
+ *
+ * Creates entries in 'pending' status for human review.
+ * Call this from a scheduled job or after honeypot hits.
+ */
+ public function syncFromHoneypot(): int
+ {
+ if (! DB::getSchemaBuilder()->hasTable('honeypot_hits')) {
+ return 0;
+ }
+
+ $criticalIps = DB::table('honeypot_hits')
+ ->where('severity', 'critical')
+ ->where('created_at', '>=', now()->subDay())
+ ->distinct()
+ ->pluck('ip_address');
+
+ $count = 0;
+ foreach ($criticalIps as $ip) {
+ $exists = DB::table('blocked_ips')
+ ->where('ip_address', $ip)
+ ->exists();
+
+ if (! $exists) {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => $ip,
+ 'reason' => 'honeypot_critical',
+ 'status' => self::STATUS_PENDING,
+ 'blocked_at' => now(),
+ 'expires_at' => now()->addDays(7),
+ ]);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Get pending entries awaiting human review.
+ *
+ * @param int|null $perPage Number of entries per page. Pass null for all entries (legacy behavior).
+ * @return array|LengthAwarePaginator Array if $perPage is null, paginator otherwise.
+ */
+ public function getPending(?int $perPage = null): array|LengthAwarePaginator
+ {
+ if (! $this->tableExists()) {
+ return $perPage === null
+ ? []
+ : new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
+ }
+
+ $query = DB::table('blocked_ips')
+ ->where('status', self::STATUS_PENDING)
+ ->orderBy('blocked_at', 'desc');
+
+ if ($perPage === null) {
+ return $query->get()->toArray();
+ }
+
+ return $query->paginate($perPage);
+ }
+
+ /**
+ * Approve a pending block entry.
+ */
+ public function approve(string $ip): bool
+ {
+ $updated = DB::table('blocked_ips')
+ ->where('ip_address', $ip)
+ ->where('status', self::STATUS_PENDING)
+ ->update(['status' => self::STATUS_APPROVED]);
+
+ if ($updated > 0) {
+ $this->clearCache();
+ }
+
+ return $updated > 0;
+ }
+
+ /**
+ * Reject a pending block entry.
+ */
+ public function reject(string $ip): bool
+ {
+ $updated = DB::table('blocked_ips')
+ ->where('ip_address', $ip)
+ ->where('status', self::STATUS_PENDING)
+ ->update(['status' => self::STATUS_REJECTED]);
+
+ return $updated > 0;
+ }
+
+ /**
+ * Clear the cache.
+ */
+ public function clearCache(): void
+ {
+ Cache::forget(self::CACHE_KEY);
+ }
+
+ /**
+ * Get stats for dashboard.
+ */
+ public function getStats(): array
+ {
+ if (! $this->tableExists()) {
+ return [
+ 'total_blocked' => 0,
+ 'active_blocked' => 0,
+ 'pending_review' => 0,
+ 'by_reason' => [],
+ 'by_status' => [],
+ ];
+ }
+
+ return [
+ 'total_blocked' => DB::table('blocked_ips')->count(),
+ 'active_blocked' => DB::table('blocked_ips')
+ ->where('status', self::STATUS_APPROVED)
+ ->where(function ($query) {
+ $query->whereNull('expires_at')
+ ->orWhere('expires_at', '>', now());
+ })
+ ->count(),
+ 'pending_review' => DB::table('blocked_ips')
+ ->where('status', self::STATUS_PENDING)
+ ->count(),
+ 'by_reason' => DB::table('blocked_ips')
+ ->selectRaw('reason, COUNT(*) as count')
+ ->groupBy('reason')
+ ->pluck('count', 'reason')
+ ->toArray(),
+ 'by_status' => DB::table('blocked_ips')
+ ->selectRaw('status, COUNT(*) as count')
+ ->groupBy('status')
+ ->pluck('count', 'status')
+ ->toArray(),
+ ];
+ }
+}
diff --git a/src/Core/Bouncer/Boot.php b/src/Core/Bouncer/Boot.php
new file mode 100644
index 0000000..1c90ea6
--- /dev/null
+++ b/src/Core/Bouncer/Boot.php
@@ -0,0 +1,101 @@
+ [
+ * 'honeypot' => [
+ * 'critical_paths' => [
+ * 'admin',
+ * 'wp-admin',
+ * '.env',
+ * '.git',
+ * 'backup', // Add custom paths
+ * 'config.php',
+ * ],
+ * ],
+ * ],
+ * ```
+ *
+ * ### Blocking Workflow
+ *
+ * 1. Bot hits a honeypot path (e.g., /admin)
+ * 2. Path is checked against `critical_paths` (prefix matching)
+ * 3. If critical and `auto_block_critical` is true, IP is blocked immediately
+ * 4. Otherwise, entry is added to `honeypot_hits` with 'pending' status
+ * 5. Admin reviews pending entries via `BlocklistService::getPending()`
+ * 6. Admin approves or rejects via `approve($ip)` or `reject($ip)`
+ *
+ * ### Rate Limiting
+ *
+ * To prevent DoS via log flooding, honeypot logging is rate-limited:
+ * - Default: 10 entries per IP per minute
+ * - Exceeded entries are silently dropped
+ * - Rate limit uses Laravel's RateLimiter facade
+ *
+ * @see BlocklistService For IP blocking functionality
+ * @see BouncerMiddleware For the early-exit middleware
+ */
+class Boot extends ServiceProvider
+{
+ public function register(): void
+ {
+ $this->app->singleton(BlocklistService::class);
+ $this->app->singleton(RedirectService::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(__DIR__.'/Migrations');
+ }
+}
diff --git a/src/Core/Bouncer/BouncerMiddleware.php b/src/Core/Bouncer/BouncerMiddleware.php
new file mode 100644
index 0000000..0968820
--- /dev/null
+++ b/src/Core/Bouncer/BouncerMiddleware.php
@@ -0,0 +1,91 @@
+setTrustedProxies($request);
+
+ $ip = $request->ip();
+ $path = $request->path();
+
+ // Check blocklist - fastest rejection
+ if ($this->blocklist->isBlocked($ip)) {
+ return $this->blockedResponse($ip);
+ }
+
+ // Check SEO redirects
+ if ($redirect = $this->redirects->match($path)) {
+ return redirect($redirect['to'], $redirect['status']);
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * Configure trusted proxies for correct client IP detection.
+ *
+ * TRUSTED_PROXIES env var: comma-separated IPs or '*' for all.
+ * Production: set to load balancer IPs (e.g., hermes.lb.host.uk.com)
+ * Development: defaults to '*' (trust all)
+ */
+ protected function setTrustedProxies(Request $request): void
+ {
+ $trustedProxies = env('TRUSTED_PROXIES', '*');
+
+ $proxies = $trustedProxies === '*'
+ ? $request->server->get('REMOTE_ADDR') // Trust the immediate proxy
+ : explode(',', $trustedProxies);
+
+ $request->setTrustedProxies(
+ is_array($proxies) ? $proxies : [$proxies],
+ Request::HEADER_X_FORWARDED_FOR |
+ Request::HEADER_X_FORWARDED_HOST |
+ Request::HEADER_X_FORWARDED_PORT |
+ Request::HEADER_X_FORWARDED_PROTO
+ );
+ }
+
+ /**
+ * Response for blocked IPs - minimal processing.
+ */
+ protected function blockedResponse(string $ip): Response
+ {
+ return response('🫖', 418, [
+ 'Content-Type' => 'text/plain',
+ 'X-Blocked' => 'true',
+ 'X-Powered-By' => 'Earl Grey',
+ ]);
+ }
+}
diff --git a/src/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php b/src/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php
new file mode 100644
index 0000000..457415b
--- /dev/null
+++ b/src/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php
@@ -0,0 +1,54 @@
+ new path.
+ */
+ protected array $redirects = [
+ // Service pages
+ '/services/biohost' => '/services/bio',
+ '/services/socialhost' => '/services/social',
+ '/services/trusthost' => '/services/trust',
+ '/services/mailhost' => '/services/mail',
+ '/services/analyticshost' => '/services/analytics',
+ '/services/notifyhost' => '/services/notify',
+
+ // MCP documentation
+ '/developers/mcp/biohost' => '/developers/mcp/bio',
+ '/developers/mcp/socialhost' => '/developers/mcp/social',
+ ];
+
+ public function __construct(
+ protected RedirectService $service,
+ ) {}
+
+ public function run(): void
+ {
+ foreach ($this->redirects as $from => $to) {
+ $this->service->add($from, $to, 301);
+ }
+
+ $this->command?->info('Seeded '.count($this->redirects).' website redirects.');
+ }
+}
diff --git a/src/Core/Bouncer/Gate/ActionGateMiddleware.php b/src/Core/Bouncer/Gate/ActionGateMiddleware.php
new file mode 100644
index 0000000..580be0b
--- /dev/null
+++ b/src/Core/Bouncer/Gate/ActionGateMiddleware.php
@@ -0,0 +1,155 @@
+ BouncerGate (action whitelisting) -> Laravel Gate/Policy -> Controller
+ * ```
+ *
+ * ## Behavior by Mode
+ *
+ * **Production (training_mode = false):**
+ * - Allowed actions proceed normally
+ * - Unknown/denied actions return 403 Forbidden
+ *
+ * **Training Mode (training_mode = true):**
+ * - Allowed actions proceed normally
+ * - Unknown actions return a training response:
+ * - API requests: JSON with action details and approval prompt
+ * - Web requests: Redirect back with flash message
+ */
+class ActionGateMiddleware
+{
+ public function __construct(
+ protected ActionGateService $gateService,
+ ) {}
+
+ public function handle(Request $request, Closure $next): Response
+ {
+ // Skip for routes that explicitly bypass the gate
+ if ($request->route()?->getAction('bypass_gate')) {
+ return $next($request);
+ }
+
+ $result = $this->gateService->check($request);
+
+ return match ($result['result']) {
+ ActionGateService::RESULT_ALLOWED => $next($request),
+ ActionGateService::RESULT_TRAINING => $this->trainingResponse($request, $result),
+ default => $this->deniedResponse($request, $result),
+ };
+ }
+
+ /**
+ * Generate response for training mode.
+ */
+ protected function trainingResponse(Request $request, array $result): Response
+ {
+ $action = $result['action'];
+ $scope = $result['scope'];
+
+ if ($this->wantsJson($request)) {
+ return $this->trainingJsonResponse($request, $action, $scope);
+ }
+
+ return $this->trainingWebResponse($request, $action, $scope);
+ }
+
+ /**
+ * JSON response for training mode (API requests).
+ */
+ protected function trainingJsonResponse(Request $request, string $action, ?string $scope): JsonResponse
+ {
+ return response()->json([
+ 'error' => 'action_not_trained',
+ 'message' => "Action '{$action}' is not trained. Approve this action to continue.",
+ 'action' => $action,
+ 'scope' => $scope,
+ 'route' => $request->path(),
+ 'method' => $request->method(),
+ 'training_mode' => true,
+ 'approval_url' => $this->approvalUrl($action, $scope, $request),
+ ], 403);
+ }
+
+ /**
+ * Web response for training mode (browser requests).
+ */
+ protected function trainingWebResponse(Request $request, string $action, ?string $scope): RedirectResponse
+ {
+ $message = "Action '{$action}' requires training approval.";
+
+ return redirect()
+ ->back()
+ ->with('bouncer_training', [
+ 'action' => $action,
+ 'scope' => $scope,
+ 'route' => $request->path(),
+ 'method' => $request->method(),
+ 'message' => $message,
+ ])
+ ->withInput();
+ }
+
+ /**
+ * Generate response for denied action.
+ */
+ protected function deniedResponse(Request $request, array $result): Response
+ {
+ $action = $result['action'];
+
+ if ($this->wantsJson($request)) {
+ return response()->json([
+ 'error' => 'action_denied',
+ 'message' => "Action '{$action}' is not permitted.",
+ 'action' => $action,
+ ], 403);
+ }
+
+ abort(403, "Action '{$action}' is not permitted.");
+ }
+
+ /**
+ * Check if request expects JSON response.
+ */
+ protected function wantsJson(Request $request): bool
+ {
+ return $request->expectsJson()
+ || $request->is('api/*')
+ || $request->header('Accept') === 'application/json';
+ }
+
+ /**
+ * Generate URL for approving an action.
+ */
+ protected function approvalUrl(string $action, ?string $scope, Request $request): string
+ {
+ return route('bouncer.gate.approve', [
+ 'action' => $action,
+ 'scope' => $scope,
+ 'redirect' => $request->fullUrl(),
+ ]);
+ }
+}
diff --git a/src/Core/Bouncer/Gate/ActionGateService.php b/src/Core/Bouncer/Gate/ActionGateService.php
new file mode 100644
index 0000000..975164a
--- /dev/null
+++ b/src/Core/Bouncer/Gate/ActionGateService.php
@@ -0,0 +1,370 @@
+ ActionGateMiddleware -> ActionGateService::check() -> Controller
+ * |
+ * v
+ * ActionPermission
+ * (allowed/denied)
+ * ```
+ *
+ * ## Action Resolution Priority
+ *
+ * 1. Route action (via `->action('name')` macro)
+ * 2. Controller method attribute (`#[Action('name')]`)
+ * 3. Auto-resolved from controller@method
+ */
+class ActionGateService
+{
+ /**
+ * Result of permission check.
+ */
+ public const RESULT_ALLOWED = 'allowed';
+
+ public const RESULT_DENIED = 'denied';
+
+ public const RESULT_TRAINING = 'training';
+
+ /**
+ * Cache of resolved action names.
+ *
+ * @var array
+ */
+ protected array $actionCache = [];
+
+ /**
+ * Check if an action is permitted.
+ *
+ * @return array{result: string, action: string, scope: string|null}
+ */
+ public function check(Request $request): array
+ {
+ $route = $request->route();
+
+ if (! $route instanceof Route) {
+ return $this->denied('unknown', null);
+ }
+
+ // Resolve action name and scope
+ $resolved = $this->resolveAction($route);
+ $action = $resolved['action'];
+ $scope = $resolved['scope'];
+
+ // Determine guard and role
+ $guard = $this->resolveGuard($route);
+ $role = $this->resolveRole($request);
+
+ // Check permission
+ $allowed = ActionPermission::isAllowed($action, $guard, $role, $scope);
+
+ // Log the request
+ $status = $allowed
+ ? ActionRequest::STATUS_ALLOWED
+ : ($this->isTrainingMode() ? ActionRequest::STATUS_PENDING : ActionRequest::STATUS_DENIED);
+
+ ActionRequest::log(
+ method: $request->method(),
+ route: $request->path(),
+ action: $action,
+ guard: $guard,
+ status: $status,
+ scope: $scope,
+ role: $role,
+ userId: $request->user()?->id,
+ ipAddress: $request->ip(),
+ );
+
+ if ($allowed) {
+ return $this->allowed($action, $scope);
+ }
+
+ if ($this->isTrainingMode()) {
+ return $this->training($action, $scope);
+ }
+
+ return $this->denied($action, $scope);
+ }
+
+ /**
+ * Allow an action (create permission).
+ */
+ public function allow(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null,
+ ?string $route = null,
+ ?int $trainedBy = null
+ ): ActionPermission {
+ return ActionPermission::train($action, $guard, $role, $scope, $route, $trainedBy);
+ }
+
+ /**
+ * Deny an action (revoke permission).
+ */
+ public function deny(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null
+ ): bool {
+ return ActionPermission::revoke($action, $guard, $role, $scope);
+ }
+
+ /**
+ * Check if training mode is enabled.
+ */
+ public function isTrainingMode(): bool
+ {
+ return (bool) config('core.bouncer.training_mode', false);
+ }
+
+ /**
+ * Resolve the action name for a route.
+ *
+ * @return array{action: string, scope: string|null}
+ */
+ public function resolveAction(Route $route): array
+ {
+ $cacheKey = $route->getName() ?? $route->uri();
+
+ if (isset($this->actionCache[$cacheKey])) {
+ return $this->actionCache[$cacheKey];
+ }
+
+ // 1. Check for explicit route action
+ $routeAction = $route->getAction('bouncer_action');
+ if ($routeAction) {
+ $result = [
+ 'action' => $routeAction,
+ 'scope' => $route->getAction('bouncer_scope'),
+ ];
+ $this->actionCache[$cacheKey] = $result;
+
+ return $result;
+ }
+
+ // 2. Check controller method attribute (requires container)
+ try {
+ $controller = $route->getController();
+ $method = $route->getActionMethod();
+
+ if ($controller !== null && $method !== 'Closure') {
+ $attributeResult = $this->resolveFromAttribute($controller, $method);
+ if ($attributeResult !== null) {
+ $this->actionCache[$cacheKey] = $attributeResult;
+
+ return $attributeResult;
+ }
+ }
+ } catch (\Throwable) {
+ // Container not available or controller doesn't exist
+ // Fall through to auto-resolution
+ }
+
+ // 3. Auto-resolve from controller@method
+ $result = [
+ 'action' => $this->autoResolveAction($route),
+ 'scope' => null,
+ ];
+ $this->actionCache[$cacheKey] = $result;
+
+ return $result;
+ }
+
+ /**
+ * Resolve action from controller/method attribute.
+ *
+ * @return array{action: string, scope: string|null}|null
+ */
+ protected function resolveFromAttribute(object $controller, string $method): ?array
+ {
+ try {
+ $reflection = new ReflectionMethod($controller, $method);
+ $attributes = $reflection->getAttributes(Action::class);
+
+ if (empty($attributes)) {
+ // Check class-level attribute as fallback
+ $classReflection = new ReflectionClass($controller);
+ $attributes = $classReflection->getAttributes(Action::class);
+ }
+
+ if (! empty($attributes)) {
+ /** @var Action $action */
+ $action = $attributes[0]->newInstance();
+
+ return [
+ 'action' => $action->name,
+ 'scope' => $action->scope,
+ ];
+ }
+ } catch (\ReflectionException) {
+ // Fall through to auto-resolution
+ }
+
+ return null;
+ }
+
+ /**
+ * Auto-resolve action name from controller and method.
+ *
+ * Examples:
+ * - ProductController@store -> product.store
+ * - Admin\UserController@index -> admin.user.index
+ * - Api\V1\OrderController@show -> api.v1.order.show
+ */
+ protected function autoResolveAction(Route $route): string
+ {
+ $uses = $route->getAction('uses');
+
+ if (is_string($uses) && str_contains($uses, '@')) {
+ [$controllerClass, $method] = explode('@', $uses);
+
+ // Remove 'Controller' suffix and convert to dot notation
+ $parts = explode('\\', $controllerClass);
+ $parts = array_map(function ($part) {
+ // Remove 'Controller' suffix
+ if (str_ends_with($part, 'Controller')) {
+ $part = substr($part, 0, -10);
+ }
+
+ // Convert PascalCase to snake_case, then to kebab-case dots
+ return strtolower(preg_replace('/(? ! in_array($p, ['app', 'http', 'controllers']));
+
+ $parts[] = strtolower($method);
+
+ return implode('.', array_values($parts));
+ }
+
+ // Fallback for closures or invokable controllers
+ return 'route.'.($route->getName() ?? $route->uri());
+ }
+
+ /**
+ * Resolve the guard from route middleware.
+ */
+ protected function resolveGuard(Route $route): string
+ {
+ $middleware = $route->gatherMiddleware();
+
+ foreach (['admin', 'api', 'client', 'web'] as $guard) {
+ if (in_array($guard, $middleware)) {
+ return $guard;
+ }
+ }
+
+ return 'web';
+ }
+
+ /**
+ * Resolve the user's role.
+ */
+ protected function resolveRole(Request $request): ?string
+ {
+ $user = $request->user();
+
+ if (! $user) {
+ return null;
+ }
+
+ // Common role resolution strategies
+ if (method_exists($user, 'getRole')) {
+ return $user->getRole();
+ }
+
+ if (method_exists($user, 'role') && is_callable([$user, 'role'])) {
+ $role = $user->role();
+
+ return is_object($role) ? ($role->name ?? null) : $role;
+ }
+
+ if (property_exists($user, 'role')) {
+ return $user->role;
+ }
+
+ return null;
+ }
+
+ /**
+ * Build an allowed result.
+ *
+ * @return array{result: string, action: string, scope: string|null}
+ */
+ protected function allowed(string $action, ?string $scope): array
+ {
+ return [
+ 'result' => self::RESULT_ALLOWED,
+ 'action' => $action,
+ 'scope' => $scope,
+ ];
+ }
+
+ /**
+ * Build a denied result.
+ *
+ * @return array{result: string, action: string, scope: string|null}
+ */
+ protected function denied(string $action, ?string $scope): array
+ {
+ return [
+ 'result' => self::RESULT_DENIED,
+ 'action' => $action,
+ 'scope' => $scope,
+ ];
+ }
+
+ /**
+ * Build a training mode result.
+ *
+ * @return array{result: string, action: string, scope: string|null}
+ */
+ protected function training(string $action, ?string $scope): array
+ {
+ return [
+ 'result' => self::RESULT_TRAINING,
+ 'action' => $action,
+ 'scope' => $scope,
+ ];
+ }
+
+ /**
+ * Clear the action resolution cache.
+ */
+ public function clearCache(): void
+ {
+ $this->actionCache = [];
+ }
+}
diff --git a/src/Core/Bouncer/Gate/Attributes/Action.php b/src/Core/Bouncer/Gate/Attributes/Action.php
new file mode 100644
index 0000000..2744afd
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Attributes/Action.php
@@ -0,0 +1,63 @@
+ `product.store`
+ * - `Admin\UserController@index` -> `admin.user.index`
+ */
+#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
+class Action
+{
+ /**
+ * Create a new Action attribute.
+ *
+ * @param string $name The action identifier (e.g., 'product.create')
+ * @param string|null $scope Optional scope for resource-specific permissions
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly ?string $scope = null,
+ ) {}
+}
diff --git a/src/Core/Bouncer/Gate/Boot.php b/src/Core/Bouncer/Gate/Boot.php
new file mode 100644
index 0000000..361c75c
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Boot.php
@@ -0,0 +1,150 @@
+ ActionGateMiddleware -> Laravel Gate/Policy -> Controller
+ * ```
+ *
+ * ## Configuration
+ *
+ * See `config/core.php` under the 'bouncer' key for all options.
+ */
+class Boot extends ServiceProvider
+{
+ /**
+ * Configure action gate middleware.
+ *
+ * Call this from your application's bootstrap to add the gate to middleware groups.
+ *
+ * ```php
+ * // bootstrap/app.php
+ * ->withMiddleware(function (Middleware $middleware) {
+ * \Core\Bouncer\Gate\Boot::middleware($middleware);
+ * })
+ * ```
+ */
+ public static function middleware(Middleware $middleware): void
+ {
+ // Add to specific middleware groups that should be gated
+ $guardedGroups = config('core.bouncer.guarded_middleware', ['web', 'admin', 'api', 'client']);
+
+ foreach ($guardedGroups as $group) {
+ $middleware->appendToGroup($group, ActionGateMiddleware::class);
+ }
+
+ // Register middleware alias for manual use
+ $middleware->alias([
+ 'action.gate' => ActionGateMiddleware::class,
+ ]);
+ }
+
+ public function register(): void
+ {
+ // Register as singleton for caching benefits
+ $this->app->singleton(ActionGateService::class);
+
+ // Merge config defaults
+ $this->mergeConfigFrom(
+ dirname(__DIR__, 2).'/config.php',
+ 'core'
+ );
+ }
+
+ public function boot(): void
+ {
+ // Skip if disabled
+ if (! config('core.bouncer.enabled', true)) {
+ return;
+ }
+
+ // Load migrations
+ $this->loadMigrationsFrom(__DIR__.'/Migrations');
+
+ // Register route macros
+ RouteActionMacro::register();
+
+ // Register training/approval routes if in training mode
+ if (config('core.bouncer.training_mode', false)) {
+ $this->registerTrainingRoutes();
+ }
+ }
+
+ /**
+ * Register routes for training mode approval workflow.
+ */
+ protected function registerTrainingRoutes(): void
+ {
+ Route::middleware(['web', 'auth'])
+ ->prefix('_bouncer')
+ ->name('bouncer.gate.')
+ ->group(function () {
+ // Approve an action
+ Route::post('/approve', function () {
+ $action = request('action');
+ $scope = request('scope');
+ $redirect = request('redirect', '/');
+
+ if (! $action) {
+ return back()->with('error', 'No action specified');
+ }
+
+ $guard = request('guard', 'web');
+ $role = request('role');
+
+ app(ActionGateService::class)->allow(
+ action: $action,
+ guard: $guard,
+ role: $role,
+ scope: $scope,
+ route: request('route'),
+ trainedBy: auth()->id(),
+ );
+
+ return redirect($redirect)->with('success', "Action '{$action}' has been approved.");
+ })->name('approve');
+
+ // List pending actions
+ Route::get('/pending', function () {
+ $pending = Models\ActionRequest::pending()
+ ->groupBy('action')
+ ->map(fn ($requests) => [
+ 'action' => $requests->first()->action,
+ 'count' => $requests->count(),
+ 'routes' => $requests->pluck('route')->unique()->values(),
+ 'last_at' => $requests->max('created_at'),
+ ])
+ ->values();
+
+ if (request()->wantsJson()) {
+ return response()->json(['pending' => $pending]);
+ }
+
+ return view('bouncer::pending', ['pending' => $pending]);
+ })->name('pending');
+ });
+ }
+}
diff --git a/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php b/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php
new file mode 100644
index 0000000..33fe98b
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php
@@ -0,0 +1,77 @@
+id();
+ $table->string('action'); // product.create, order.refund
+ $table->string('scope')->nullable(); // Resource type or specific ID
+ $table->string('guard')->default('web'); // web, api, admin
+ $table->string('role')->nullable(); // admin, editor, or null for any auth
+ $table->boolean('allowed')->default(false);
+ $table->string('source'); // 'trained', 'seeded', 'manual'
+ $table->string('trained_route')->nullable();
+ $table->foreignId('trained_by')->nullable();
+ $table->timestamp('trained_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['action', 'scope', 'guard', 'role'], 'action_permission_unique');
+ $table->index('action');
+ $table->index(['guard', 'allowed']);
+ });
+
+ // 2. Action Requests (audit log)
+ Schema::create('core_action_requests', function (Blueprint $table) {
+ $table->id();
+ $table->string('method', 10); // GET, POST, etc.
+ $table->string('route'); // /admin/products
+ $table->string('action'); // product.create
+ $table->string('scope')->nullable();
+ $table->string('guard'); // web, api, admin
+ $table->string('role')->nullable();
+ $table->foreignId('user_id')->nullable();
+ $table->string('ip_address', 45)->nullable();
+ $table->string('status', 20); // allowed, denied, pending
+ $table->boolean('was_trained')->default(false);
+ $table->timestamps();
+
+ $table->index(['action', 'status']);
+ $table->index(['user_id', 'created_at']);
+ $table->index('status');
+ });
+
+ Schema::enableForeignKeyConstraints();
+ }
+
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('core_action_requests');
+ Schema::dropIfExists('core_action_permissions');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/src/Core/Bouncer/Gate/Models/ActionPermission.php b/src/Core/Bouncer/Gate/Models/ActionPermission.php
new file mode 100644
index 0000000..a727539
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Models/ActionPermission.php
@@ -0,0 +1,205 @@
+ 'boolean',
+ 'trained_at' => 'datetime',
+ ];
+
+ /**
+ * Source constants.
+ */
+ public const SOURCE_TRAINED = 'trained';
+
+ public const SOURCE_SEEDED = 'seeded';
+
+ public const SOURCE_MANUAL = 'manual';
+
+ /**
+ * User who trained this permission.
+ */
+ public function trainer(): BelongsTo
+ {
+ return $this->belongsTo(config('auth.providers.users.model'), 'trained_by');
+ }
+
+ /**
+ * Check if action is allowed for the given context.
+ */
+ public static function isAllowed(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null
+ ): bool {
+ $query = static::query()
+ ->where('action', $action)
+ ->where('guard', $guard)
+ ->where('allowed', true);
+
+ // Check scope match (null matches any, or exact match)
+ if ($scope !== null) {
+ $query->where(function ($q) use ($scope) {
+ $q->whereNull('scope')
+ ->orWhere('scope', $scope);
+ });
+ }
+
+ // Check role match (null role in permission = any authenticated)
+ if ($role !== null) {
+ $query->where(function ($q) use ($role) {
+ $q->whereNull('role')
+ ->orWhere('role', $role);
+ });
+ } else {
+ // No role provided, only match null role permissions
+ $query->whereNull('role');
+ }
+
+ return $query->exists();
+ }
+
+ /**
+ * Find or create a permission for the given action context.
+ */
+ public static function findOrCreateFor(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null
+ ): self {
+ return static::firstOrCreate(
+ [
+ 'action' => $action,
+ 'guard' => $guard,
+ 'role' => $role,
+ 'scope' => $scope,
+ ],
+ [
+ 'allowed' => false,
+ 'source' => self::SOURCE_MANUAL,
+ ]
+ );
+ }
+
+ /**
+ * Train (allow) an action.
+ */
+ public static function train(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null,
+ ?string $route = null,
+ ?int $trainedBy = null
+ ): self {
+ $permission = static::findOrCreateFor($action, $guard, $role, $scope);
+
+ $permission->update([
+ 'allowed' => true,
+ 'source' => self::SOURCE_TRAINED,
+ 'trained_route' => $route,
+ 'trained_by' => $trainedBy,
+ 'trained_at' => now(),
+ ]);
+
+ return $permission;
+ }
+
+ /**
+ * Revoke an action permission.
+ */
+ public static function revoke(
+ string $action,
+ string $guard = 'web',
+ ?string $role = null,
+ ?string $scope = null
+ ): bool {
+ return static::query()
+ ->where('action', $action)
+ ->where('guard', $guard)
+ ->where('role', $role)
+ ->where('scope', $scope)
+ ->update(['allowed' => false]) > 0;
+ }
+
+ /**
+ * Get all actions for a guard.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forGuard(string $guard): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('guard', $guard)->get();
+ }
+
+ /**
+ * Get all allowed actions for a guard/role combination.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function allowedFor(string $guard, ?string $role = null): \Illuminate\Database\Eloquent\Collection
+ {
+ $query = static::where('guard', $guard)
+ ->where('allowed', true);
+
+ if ($role !== null) {
+ $query->where(function ($q) use ($role) {
+ $q->whereNull('role')
+ ->orWhere('role', $role);
+ });
+ } else {
+ $query->whereNull('role');
+ }
+
+ return $query->get();
+ }
+}
diff --git a/src/Core/Bouncer/Gate/Models/ActionRequest.php b/src/Core/Bouncer/Gate/Models/ActionRequest.php
new file mode 100644
index 0000000..393776a
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Models/ActionRequest.php
@@ -0,0 +1,179 @@
+ 'boolean',
+ ];
+
+ /**
+ * Status constants.
+ */
+ public const STATUS_ALLOWED = 'allowed';
+
+ public const STATUS_DENIED = 'denied';
+
+ public const STATUS_PENDING = 'pending';
+
+ /**
+ * User who made the request.
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(config('auth.providers.users.model'), 'user_id');
+ }
+
+ /**
+ * Log an action request.
+ */
+ public static function log(
+ string $method,
+ string $route,
+ string $action,
+ string $guard,
+ string $status,
+ ?string $scope = null,
+ ?string $role = null,
+ ?int $userId = null,
+ ?string $ipAddress = null,
+ bool $wasTrained = false
+ ): self {
+ return static::create([
+ 'method' => $method,
+ 'route' => $route,
+ 'action' => $action,
+ 'scope' => $scope,
+ 'guard' => $guard,
+ 'role' => $role,
+ 'user_id' => $userId,
+ 'ip_address' => $ipAddress,
+ 'status' => $status,
+ 'was_trained' => $wasTrained,
+ ]);
+ }
+
+ /**
+ * Get pending requests (for training review).
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function pending(): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('status', self::STATUS_PENDING)
+ ->orderBy('created_at', 'desc')
+ ->get();
+ }
+
+ /**
+ * Get denied requests for an action.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function deniedFor(string $action): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('action', $action)
+ ->where('status', self::STATUS_DENIED)
+ ->orderBy('created_at', 'desc')
+ ->get();
+ }
+
+ /**
+ * Get requests by user.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forUser(int $userId): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('user_id', $userId)
+ ->orderBy('created_at', 'desc')
+ ->get();
+ }
+
+ /**
+ * Get unique actions that were denied (candidates for training).
+ *
+ * @return array
+ */
+ public static function deniedActionsSummary(): array
+ {
+ return static::where('status', self::STATUS_DENIED)
+ ->selectRaw('action, COUNT(*) as count, MAX(created_at) as last_at')
+ ->groupBy('action')
+ ->orderByDesc('count')
+ ->get()
+ ->keyBy('action')
+ ->map(fn ($row) => [
+ 'action' => $row->action,
+ 'count' => (int) $row->count,
+ 'last_at' => $row->last_at,
+ ])
+ ->toArray();
+ }
+
+ /**
+ * Prune old request logs.
+ */
+ public static function prune(int $days = 30): int
+ {
+ return static::where('created_at', '<', now()->subDays($days))
+ ->delete();
+ }
+
+ /**
+ * Mark this request as having triggered training.
+ */
+ public function markTrained(): self
+ {
+ $this->update(['was_trained' => true]);
+
+ return $this;
+ }
+}
diff --git a/src/Core/Bouncer/Gate/RouteActionMacro.php b/src/Core/Bouncer/Gate/RouteActionMacro.php
new file mode 100644
index 0000000..16ff3b2
--- /dev/null
+++ b/src/Core/Bouncer/Gate/RouteActionMacro.php
@@ -0,0 +1,86 @@
+action('product.create');
+ *
+ * Route::delete('/products/{product}', [ProductController::class, 'destroy'])
+ * ->action('product.delete', scope: 'product');
+ *
+ * Route::get('/public-page', PageController::class)
+ * ->bypassGate(); // Skip action gate entirely
+ * ```
+ */
+class RouteActionMacro
+{
+ /**
+ * Register route macros for action gate.
+ */
+ public static function register(): void
+ {
+ /**
+ * Set the action name for bouncer gate checking.
+ *
+ * @param string $action The action identifier (e.g., 'product.create')
+ * @param string|null $scope Optional resource scope
+ * @return Route
+ */
+ Route::macro('action', function (string $action, ?string $scope = null): Route {
+ /** @var Route $this */
+ $this->setAction(array_merge($this->getAction(), [
+ 'bouncer_action' => $action,
+ 'bouncer_scope' => $scope,
+ ]));
+
+ return $this;
+ });
+
+ /**
+ * Bypass the action gate for this route.
+ *
+ * Use sparingly for routes that should never be gated (e.g., login page).
+ *
+ * @return Route
+ */
+ Route::macro('bypassGate', function (): Route {
+ /** @var Route $this */
+ $this->setAction(array_merge($this->getAction(), [
+ 'bypass_gate' => true,
+ ]));
+
+ return $this;
+ });
+
+ /**
+ * Mark this route as requiring training (explicit pending state).
+ *
+ * @return Route
+ */
+ Route::macro('requiresTraining', function (): Route {
+ /** @var Route $this */
+ $this->setAction(array_merge($this->getAction(), [
+ 'requires_training' => true,
+ ]));
+
+ return $this;
+ });
+ }
+}
diff --git a/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php b/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php
new file mode 100644
index 0000000..555b3d9
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php
@@ -0,0 +1,388 @@
+loadMigrationsFrom(__DIR__.'/../../Migrations');
+ }
+
+ protected function getPackageProviders($app): array
+ {
+ return [
+ \Core\Bouncer\Gate\Boot::class,
+ ];
+ }
+
+ protected function defineEnvironment($app): void
+ {
+ $app['config']->set('core.bouncer.enabled', true);
+ $app['config']->set('core.bouncer.training_mode', false);
+ }
+
+ // =========================================================================
+ // ActionPermission Model Tests
+ // =========================================================================
+
+ public function test_action_permission_can_be_created(): void
+ {
+ $permission = ActionPermission::create([
+ 'action' => 'product.create',
+ 'guard' => 'web',
+ 'allowed' => true,
+ 'source' => ActionPermission::SOURCE_MANUAL,
+ ]);
+
+ $this->assertDatabaseHas('core_action_permissions', [
+ 'action' => 'product.create',
+ 'guard' => 'web',
+ 'allowed' => true,
+ ]);
+ }
+
+ public function test_is_allowed_returns_true_for_permitted_action(): void
+ {
+ ActionPermission::create([
+ 'action' => 'product.view',
+ 'guard' => 'web',
+ 'allowed' => true,
+ 'source' => ActionPermission::SOURCE_SEEDED,
+ ]);
+
+ $this->assertTrue(ActionPermission::isAllowed('product.view', 'web'));
+ }
+
+ public function test_is_allowed_returns_false_for_non_existent_action(): void
+ {
+ $this->assertFalse(ActionPermission::isAllowed('unknown.action', 'web'));
+ }
+
+ public function test_is_allowed_returns_false_for_denied_action(): void
+ {
+ ActionPermission::create([
+ 'action' => 'product.delete',
+ 'guard' => 'web',
+ 'allowed' => false,
+ 'source' => ActionPermission::SOURCE_MANUAL,
+ ]);
+
+ $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web'));
+ }
+
+ public function test_is_allowed_respects_guard(): void
+ {
+ ActionPermission::create([
+ 'action' => 'product.create',
+ 'guard' => 'admin',
+ 'allowed' => true,
+ 'source' => ActionPermission::SOURCE_SEEDED,
+ ]);
+
+ $this->assertTrue(ActionPermission::isAllowed('product.create', 'admin'));
+ $this->assertFalse(ActionPermission::isAllowed('product.create', 'web'));
+ }
+
+ public function test_is_allowed_respects_role(): void
+ {
+ ActionPermission::create([
+ 'action' => 'product.create',
+ 'guard' => 'web',
+ 'role' => 'editor',
+ 'allowed' => true,
+ 'source' => ActionPermission::SOURCE_SEEDED,
+ ]);
+
+ $this->assertTrue(ActionPermission::isAllowed('product.create', 'web', 'editor'));
+ $this->assertFalse(ActionPermission::isAllowed('product.create', 'web', 'viewer'));
+ }
+
+ public function test_null_role_permission_allows_any_role(): void
+ {
+ ActionPermission::create([
+ 'action' => 'product.view',
+ 'guard' => 'web',
+ 'role' => null,
+ 'allowed' => true,
+ 'source' => ActionPermission::SOURCE_SEEDED,
+ ]);
+
+ $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'admin'));
+ $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'editor'));
+ $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', null));
+ }
+
+ public function test_train_creates_and_allows_action(): void
+ {
+ $permission = ActionPermission::train(
+ action: 'order.refund',
+ guard: 'admin',
+ role: 'manager',
+ route: '/admin/orders/1/refund',
+ trainedBy: 1
+ );
+
+ $this->assertTrue($permission->allowed);
+ $this->assertEquals(ActionPermission::SOURCE_TRAINED, $permission->source);
+ $this->assertEquals('/admin/orders/1/refund', $permission->trained_route);
+ $this->assertEquals(1, $permission->trained_by);
+ $this->assertNotNull($permission->trained_at);
+ }
+
+ public function test_revoke_denies_action(): void
+ {
+ ActionPermission::train('product.delete', 'web');
+
+ $result = ActionPermission::revoke('product.delete', 'web');
+
+ $this->assertTrue($result);
+ $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web'));
+ }
+
+ // =========================================================================
+ // ActionRequest Model Tests
+ // =========================================================================
+
+ public function test_action_request_can_be_logged(): void
+ {
+ $request = ActionRequest::log(
+ method: 'POST',
+ route: '/products',
+ action: 'product.create',
+ guard: 'web',
+ status: ActionRequest::STATUS_ALLOWED,
+ userId: 1,
+ ipAddress: '127.0.0.1'
+ );
+
+ $this->assertDatabaseHas('core_action_requests', [
+ 'method' => 'POST',
+ 'action' => 'product.create',
+ 'status' => 'allowed',
+ ]);
+ }
+
+ public function test_pending_returns_pending_requests(): void
+ {
+ ActionRequest::log('GET', '/test', 'test.action', 'web', ActionRequest::STATUS_PENDING);
+ ActionRequest::log('POST', '/test', 'test.create', 'web', ActionRequest::STATUS_ALLOWED);
+
+ $pending = ActionRequest::pending();
+
+ $this->assertCount(1, $pending);
+ $this->assertEquals('test.action', $pending->first()->action);
+ }
+
+ public function test_denied_actions_summary_groups_by_action(): void
+ {
+ ActionRequest::log('GET', '/a', 'product.view', 'web', ActionRequest::STATUS_DENIED);
+ ActionRequest::log('GET', '/b', 'product.view', 'web', ActionRequest::STATUS_DENIED);
+ ActionRequest::log('POST', '/c', 'product.create', 'web', ActionRequest::STATUS_DENIED);
+
+ $summary = ActionRequest::deniedActionsSummary();
+
+ $this->assertArrayHasKey('product.view', $summary);
+ $this->assertEquals(2, $summary['product.view']['count']);
+ $this->assertArrayHasKey('product.create', $summary);
+ $this->assertEquals(1, $summary['product.create']['count']);
+ }
+
+ // =========================================================================
+ // ActionGateService Tests
+ // =========================================================================
+
+ public function test_service_allows_permitted_action(): void
+ {
+ ActionPermission::train('product.index', 'web');
+
+ $service = new ActionGateService;
+ $route = $this->createMockRoute('ProductController@index', 'web');
+ $request = $this->createMockRequest($route);
+
+ $result = $service->check($request);
+
+ $this->assertEquals(ActionGateService::RESULT_ALLOWED, $result['result']);
+ }
+
+ public function test_service_denies_unknown_action_in_production(): void
+ {
+ config(['core.bouncer.training_mode' => false]);
+
+ $service = new ActionGateService;
+ $route = $this->createMockRoute('ProductController@store', 'web');
+ $request = $this->createMockRequest($route);
+
+ $result = $service->check($request);
+
+ $this->assertEquals(ActionGateService::RESULT_DENIED, $result['result']);
+ }
+
+ public function test_service_returns_training_in_training_mode(): void
+ {
+ config(['core.bouncer.training_mode' => true]);
+
+ $service = new ActionGateService;
+ $route = $this->createMockRoute('OrderController@refund', 'web');
+ $request = $this->createMockRequest($route);
+
+ $result = $service->check($request);
+
+ $this->assertEquals(ActionGateService::RESULT_TRAINING, $result['result']);
+ }
+
+ public function test_service_logs_request(): void
+ {
+ ActionPermission::train('product.show', 'web');
+
+ $service = new ActionGateService;
+ $route = $this->createMockRoute('ProductController@show', 'web');
+ $request = $this->createMockRequest($route);
+
+ $service->check($request);
+
+ $this->assertDatabaseHas('core_action_requests', [
+ 'action' => 'product.show',
+ 'status' => 'allowed',
+ ]);
+ }
+
+ // =========================================================================
+ // Action Resolution Tests
+ // =========================================================================
+
+ public function test_resolves_action_from_route_action(): void
+ {
+ $service = new ActionGateService;
+
+ $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']);
+ $route->setAction(array_merge($route->getAction(), [
+ 'bouncer_action' => 'products.list',
+ 'bouncer_scope' => 'catalog',
+ ]));
+
+ $result = $service->resolveAction($route);
+
+ $this->assertEquals('products.list', $result['action']);
+ $this->assertEquals('catalog', $result['scope']);
+ }
+
+ public function test_auto_resolves_action_from_controller_method(): void
+ {
+ $service = new ActionGateService;
+
+ $route = new Route(['POST'], '/products', ['uses' => 'ProductController@store']);
+
+ $result = $service->resolveAction($route);
+
+ $this->assertEquals('product.store', $result['action']);
+ }
+
+ public function test_auto_resolves_namespaced_controller(): void
+ {
+ $service = new ActionGateService;
+
+ $route = new Route(['GET'], '/admin/users', ['uses' => 'Admin\\UserController@index']);
+
+ $result = $service->resolveAction($route);
+
+ $this->assertEquals('admin.user.index', $result['action']);
+ }
+
+ // =========================================================================
+ // Route Macro Tests
+ // =========================================================================
+
+ public function test_route_action_macro_sets_action(): void
+ {
+ $route = RouteFacade::get('/test', fn () => 'test')
+ ->action('custom.action');
+
+ $this->assertEquals('custom.action', $route->getAction('bouncer_action'));
+ }
+
+ public function test_route_action_macro_sets_scope(): void
+ {
+ $route = RouteFacade::get('/test/{id}', fn () => 'test')
+ ->action('resource.view', 'resource');
+
+ $this->assertEquals('resource.view', $route->getAction('bouncer_action'));
+ $this->assertEquals('resource', $route->getAction('bouncer_scope'));
+ }
+
+ public function test_route_bypass_gate_macro(): void
+ {
+ $route = RouteFacade::get('/login', fn () => 'login')
+ ->bypassGate();
+
+ $this->assertTrue($route->getAction('bypass_gate'));
+ }
+
+ // =========================================================================
+ // Action Attribute Tests
+ // =========================================================================
+
+ public function test_action_attribute_stores_name(): void
+ {
+ $attribute = new Action('product.create');
+
+ $this->assertEquals('product.create', $attribute->name);
+ $this->assertNull($attribute->scope);
+ }
+
+ public function test_action_attribute_stores_scope(): void
+ {
+ $attribute = new Action('product.delete', scope: 'product');
+
+ $this->assertEquals('product.delete', $attribute->name);
+ $this->assertEquals('product', $attribute->scope);
+ }
+
+ // =========================================================================
+ // Helper Methods
+ // =========================================================================
+
+ protected function createMockRoute(string $uses, string $middlewareGroup = 'web'): Route
+ {
+ $route = new Route(['GET'], '/test', ['uses' => $uses]);
+ $route->middleware($middlewareGroup);
+
+ return $route;
+ }
+
+ protected function createMockRequest(Route $route): Request
+ {
+ $request = Request::create('/test', 'GET');
+ $request->setRouteResolver(fn () => $route);
+
+ return $request;
+ }
+}
diff --git a/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php b/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php
new file mode 100644
index 0000000..e2892e4
--- /dev/null
+++ b/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php
@@ -0,0 +1,235 @@
+service = new ActionGateService;
+ }
+
+ // =========================================================================
+ // Auto-Resolution Tests (via uses action string)
+ // =========================================================================
+
+ public function test_auto_resolves_simple_controller(): void
+ {
+ $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']);
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('product.index', $result['action']);
+ }
+
+ public function test_auto_resolves_nested_namespace(): void
+ {
+ $route = new Route(['POST'], '/admin/users', ['uses' => 'Admin\\UserController@store']);
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('admin.user.store', $result['action']);
+ }
+
+ public function test_auto_resolves_deeply_nested_namespace(): void
+ {
+ $route = new Route(['GET'], '/api/v1/orders', ['uses' => 'Api\\V1\\OrderController@show']);
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('api.v1.order.show', $result['action']);
+ }
+
+ public function test_auto_resolves_pascal_case_controller(): void
+ {
+ $route = new Route(['GET'], '/user-profiles', ['uses' => 'UserProfileController@index']);
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('user_profile.index', $result['action']);
+ }
+
+ public function test_filters_common_namespace_prefixes(): void
+ {
+ $route = new Route(['GET'], '/test', ['uses' => 'App\\Http\\Controllers\\TestController@index']);
+
+ $result = $this->service->resolveAction($route);
+
+ // Should not include 'app', 'http', 'controllers'
+ $this->assertEquals('test.index', $result['action']);
+ }
+
+ // =========================================================================
+ // Route Action Override Tests
+ // =========================================================================
+
+ public function test_route_action_takes_precedence(): void
+ {
+ $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']);
+ $route->setAction(array_merge($route->getAction(), [
+ 'bouncer_action' => 'catalog.list',
+ ]));
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('catalog.list', $result['action']);
+ }
+
+ public function test_route_scope_is_preserved(): void
+ {
+ $route = new Route(['DELETE'], '/products/1', ['uses' => 'ProductController@destroy']);
+ $route->setAction(array_merge($route->getAction(), [
+ 'bouncer_action' => 'product.delete',
+ 'bouncer_scope' => 'product',
+ ]));
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('product.delete', $result['action']);
+ $this->assertEquals('product', $result['scope']);
+ }
+
+ // =========================================================================
+ // Closure/Named Route Tests
+ // =========================================================================
+
+ public function test_closure_routes_use_uri_fallback(): void
+ {
+ $route = new Route(['GET'], '/hello', fn () => 'hello');
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('route.hello', $result['action']);
+ }
+
+ public function test_named_closure_routes_use_name(): void
+ {
+ $route = new Route(['GET'], '/hello', fn () => 'hello');
+ $route->name('greeting.hello');
+
+ $result = $this->service->resolveAction($route);
+
+ $this->assertEquals('route.greeting.hello', $result['action']);
+ }
+
+ // =========================================================================
+ // Caching Tests
+ // =========================================================================
+
+ public function test_caches_resolved_actions(): void
+ {
+ $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']);
+ $route->name('products.index');
+
+ // First call
+ $result1 = $this->service->resolveAction($route);
+
+ // Second call should use cache
+ $result2 = $this->service->resolveAction($route);
+
+ $this->assertEquals($result1, $result2);
+ }
+
+ public function test_clear_cache_works(): void
+ {
+ $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']);
+ $route->name('products.index');
+
+ $this->service->resolveAction($route);
+ $this->service->clearCache();
+
+ // Should not throw - just verify it works
+ $result = $this->service->resolveAction($route);
+ $this->assertNotEmpty($result['action']);
+ }
+
+ // =========================================================================
+ // Guard Resolution Tests
+ // =========================================================================
+
+ public function test_resolves_admin_guard(): void
+ {
+ $route = new Route(['GET'], '/admin/dashboard', ['uses' => 'DashboardController@index']);
+ $route->middleware('admin');
+
+ $method = new \ReflectionMethod($this->service, 'resolveGuard');
+ $method->setAccessible(true);
+
+ $guard = $method->invoke($this->service, $route);
+
+ $this->assertEquals('admin', $guard);
+ }
+
+ public function test_resolves_api_guard(): void
+ {
+ $route = new Route(['GET'], '/api/users', ['uses' => 'UserController@index']);
+ $route->middleware('api');
+
+ $method = new \ReflectionMethod($this->service, 'resolveGuard');
+ $method->setAccessible(true);
+
+ $guard = $method->invoke($this->service, $route);
+
+ $this->assertEquals('api', $guard);
+ }
+
+ public function test_defaults_to_web_guard(): void
+ {
+ $route = new Route(['GET'], '/home', ['uses' => 'HomeController@index']);
+
+ $method = new \ReflectionMethod($this->service, 'resolveGuard');
+ $method->setAccessible(true);
+
+ $guard = $method->invoke($this->service, $route);
+
+ $this->assertEquals('web', $guard);
+ }
+
+ // =========================================================================
+ // Action Attribute Tests
+ // =========================================================================
+
+ public function test_action_attribute_stores_name(): void
+ {
+ $attribute = new Action('product.create');
+
+ $this->assertEquals('product.create', $attribute->name);
+ $this->assertNull($attribute->scope);
+ }
+
+ public function test_action_attribute_stores_scope(): void
+ {
+ $attribute = new Action('product.delete', scope: 'product');
+
+ $this->assertEquals('product.delete', $attribute->name);
+ $this->assertEquals('product', $attribute->scope);
+ }
+
+ // =========================================================================
+ // Result Builder Tests
+ // =========================================================================
+
+ public function test_result_constants_are_defined(): void
+ {
+ $this->assertEquals('allowed', ActionGateService::RESULT_ALLOWED);
+ $this->assertEquals('denied', ActionGateService::RESULT_DENIED);
+ $this->assertEquals('training', ActionGateService::RESULT_TRAINING);
+ }
+}
diff --git a/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php b/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php
new file mode 100644
index 0000000..1afcbd9
--- /dev/null
+++ b/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php
@@ -0,0 +1,68 @@
+id();
+ $table->string('ip_address', 45);
+ $table->string('ip_range', 18)->nullable();
+ $table->string('reason')->nullable();
+ $table->string('source', 32)->default('manual');
+ $table->string('status', 32)->default('active');
+ $table->unsignedInteger('hit_count')->default(0);
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('last_hit_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['ip_address', 'ip_range']);
+ $table->index(['status', 'expires_at']);
+ $table->index('ip_address');
+ });
+
+ // 2. Rate Limit Buckets
+ Schema::create('rate_limit_buckets', function (Blueprint $table) {
+ $table->id();
+ $table->string('key');
+ $table->string('bucket_type', 32);
+ $table->unsignedInteger('tokens')->default(0);
+ $table->unsignedInteger('max_tokens');
+ $table->timestamp('last_refill_at');
+ $table->timestamp('expires_at');
+ $table->timestamps();
+
+ $table->unique(['key', 'bucket_type']);
+ $table->index('expires_at');
+ });
+
+ Schema::enableForeignKeyConstraints();
+ }
+
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('rate_limit_buckets');
+ Schema::dropIfExists('blocked_ips');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/src/Core/Bouncer/RedirectService.php b/src/Core/Bouncer/RedirectService.php
new file mode 100644
index 0000000..a2365f9
--- /dev/null
+++ b/src/Core/Bouncer/RedirectService.php
@@ -0,0 +1,132 @@
+getRedirects();
+ $path = '/'.ltrim($path, '/');
+
+ // Exact match first
+ if (isset($redirects[$path])) {
+ return $redirects[$path];
+ }
+
+ // Wildcard matches (path/*)
+ foreach ($redirects as $from => $redirect) {
+ if (str_ends_with($from, '*')) {
+ $prefix = rtrim($from, '*');
+ if (str_starts_with($path, $prefix)) {
+ // Replace the matched portion
+ $suffix = substr($path, strlen($prefix));
+ $to = str_ends_with($redirect['to'], '*')
+ ? rtrim($redirect['to'], '*').$suffix
+ : $redirect['to'];
+
+ return ['to' => $to, 'status' => $redirect['status']];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get all redirects (cached).
+ *
+ * @return array
+ */
+ public function getRedirects(): array
+ {
+ return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
+ if (! $this->tableExists()) {
+ return [];
+ }
+
+ return DB::table('seo_redirects')
+ ->where('active', true)
+ ->get()
+ ->keyBy('from_path')
+ ->map(fn ($row) => [
+ 'to' => $row->to_path,
+ 'status' => $row->status_code,
+ ])
+ ->toArray();
+ });
+ }
+
+ /**
+ * Add a redirect.
+ */
+ public function add(string $from, string $to, int $status = 301): void
+ {
+ DB::table('seo_redirects')->updateOrInsert(
+ ['from_path' => $from],
+ [
+ 'to_path' => $to,
+ 'status_code' => $status,
+ 'active' => true,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]
+ );
+
+ $this->clearCache();
+ }
+
+ /**
+ * Remove a redirect.
+ */
+ public function remove(string $from): void
+ {
+ DB::table('seo_redirects')->where('from_path', $from)->delete();
+ $this->clearCache();
+ }
+
+ /**
+ * Clear the cache.
+ */
+ public function clearCache(): void
+ {
+ Cache::forget(self::CACHE_KEY);
+ }
+
+ /**
+ * Check if redirects table exists.
+ */
+ protected function tableExists(): bool
+ {
+ return Cache::remember('bouncer:redirects_table_exists', 3600, function () {
+ return DB::getSchemaBuilder()->hasTable('seo_redirects');
+ });
+ }
+}
diff --git a/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php b/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php
new file mode 100644
index 0000000..f241089
--- /dev/null
+++ b/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php
@@ -0,0 +1,594 @@
+service = new BlocklistService;
+ }
+
+ protected function defineDatabaseMigrations(): void
+ {
+ // Create blocked_ips table for testing
+ Schema::create('blocked_ips', function ($table) {
+ $table->id();
+ $table->string('ip_address', 45);
+ $table->string('ip_range', 18)->nullable();
+ $table->string('reason')->nullable();
+ $table->string('source', 32)->default('manual');
+ $table->string('status', 32)->default('active');
+ $table->unsignedInteger('hit_count')->default(0);
+ $table->timestamp('blocked_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('last_hit_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['ip_address', 'ip_range']);
+ $table->index(['status', 'expires_at']);
+ $table->index('ip_address');
+ });
+
+ // Create honeypot_hits table for testing syncFromHoneypot
+ Schema::create('honeypot_hits', function ($table) {
+ $table->id();
+ $table->string('ip_address', 45);
+ $table->string('path');
+ $table->string('severity', 32)->default('low');
+ $table->timestamps();
+ });
+ }
+
+ // =========================================================================
+ // Blocking Tests
+ // =========================================================================
+
+ public function test_block_adds_ip_to_blocklist(): void
+ {
+ $this->service->block('192.168.1.100', 'test_reason');
+
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test_reason',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ ]);
+ }
+
+ public function test_block_with_custom_status(): void
+ {
+ $this->service->block('192.168.1.100', 'honeypot', BlocklistService::STATUS_PENDING);
+
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'honeypot',
+ 'status' => BlocklistService::STATUS_PENDING,
+ ]);
+ }
+
+ public function test_block_updates_existing_entry(): void
+ {
+ // First block
+ $this->service->block('192.168.1.100', 'first_reason');
+
+ // Second block should update
+ $this->service->block('192.168.1.100', 'updated_reason');
+
+ $this->assertDatabaseCount('blocked_ips', 1);
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'updated_reason',
+ ]);
+ }
+
+ public function test_block_clears_cache(): void
+ {
+ Cache::shouldReceive('forget')
+ ->once()
+ ->with('bouncer:blocklist');
+
+ Cache::shouldReceive('remember')
+ ->andReturn([]);
+
+ $this->service->block('192.168.1.100', 'test');
+ }
+
+ // =========================================================================
+ // Unblocking Tests
+ // =========================================================================
+
+ public function test_unblock_removes_ip_from_blocklist(): void
+ {
+ $this->service->block('192.168.1.100', 'test');
+ $this->service->unblock('192.168.1.100');
+
+ $this->assertDatabaseMissing('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ ]);
+ }
+
+ public function test_unblock_clears_cache(): void
+ {
+ // First add the IP
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now(),
+ ]);
+
+ Cache::shouldReceive('forget')
+ ->once()
+ ->with('bouncer:blocklist');
+
+ $this->service->unblock('192.168.1.100');
+ }
+
+ public function test_unblock_does_not_fail_on_non_existent_ip(): void
+ {
+ // This should not throw an exception
+ $this->service->unblock('192.168.1.200');
+
+ $this->assertTrue(true);
+ }
+
+ // =========================================================================
+ // IP Blocked Check Tests
+ // =========================================================================
+
+ public function test_is_blocked_returns_true_for_blocked_ip(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now(),
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ // Clear any existing cache
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertTrue($this->service->isBlocked('192.168.1.100'));
+ }
+
+ public function test_is_blocked_returns_false_for_non_blocked_ip(): void
+ {
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertFalse($this->service->isBlocked('192.168.1.200'));
+ }
+
+ public function test_is_blocked_returns_false_for_expired_block(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now()->subDays(2),
+ 'expires_at' => now()->subDay(), // Expired yesterday
+ ]);
+
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertFalse($this->service->isBlocked('192.168.1.100'));
+ }
+
+ public function test_is_blocked_returns_false_for_pending_status(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_PENDING,
+ 'blocked_at' => now(),
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertFalse($this->service->isBlocked('192.168.1.100'));
+ }
+
+ public function test_is_blocked_returns_false_for_rejected_status(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_REJECTED,
+ 'blocked_at' => now(),
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertFalse($this->service->isBlocked('192.168.1.100'));
+ }
+
+ public function test_is_blocked_works_with_null_expiry(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'permanent',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now(),
+ 'expires_at' => null, // Permanent block
+ ]);
+
+ Cache::forget('bouncer:blocklist');
+ Cache::forget('bouncer:blocked_ips_table_exists');
+
+ $this->assertTrue($this->service->isBlocked('192.168.1.100'));
+ }
+
+ // =========================================================================
+ // Sync From Honeypot Tests
+ // =========================================================================
+
+ public function test_sync_from_honeypot_adds_critical_hits(): void
+ {
+ // Insert honeypot critical hits
+ DB::table('honeypot_hits')->insert([
+ ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()],
+ ['ip_address' => '10.0.0.2', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()],
+ ]);
+
+ $count = $this->service->syncFromHoneypot();
+
+ $this->assertEquals(2, $count);
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '10.0.0.1',
+ 'reason' => 'honeypot_critical',
+ 'status' => BlocklistService::STATUS_PENDING,
+ ]);
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '10.0.0.2',
+ 'reason' => 'honeypot_critical',
+ 'status' => BlocklistService::STATUS_PENDING,
+ ]);
+ }
+
+ public function test_sync_from_honeypot_ignores_non_critical_hits(): void
+ {
+ DB::table('honeypot_hits')->insert([
+ ['ip_address' => '10.0.0.1', 'path' => '/robots.txt', 'severity' => 'low', 'created_at' => now()],
+ ['ip_address' => '10.0.0.2', 'path' => '/favicon.ico', 'severity' => 'medium', 'created_at' => now()],
+ ]);
+
+ $count = $this->service->syncFromHoneypot();
+
+ $this->assertEquals(0, $count);
+ $this->assertDatabaseCount('blocked_ips', 0);
+ }
+
+ public function test_sync_from_honeypot_ignores_old_hits(): void
+ {
+ DB::table('honeypot_hits')->insert([
+ 'ip_address' => '10.0.0.1',
+ 'path' => '/admin',
+ 'severity' => 'critical',
+ 'created_at' => now()->subDays(2), // Older than 24 hours
+ ]);
+
+ $count = $this->service->syncFromHoneypot();
+
+ $this->assertEquals(0, $count);
+ $this->assertDatabaseCount('blocked_ips', 0);
+ }
+
+ public function test_sync_from_honeypot_skips_already_blocked_ips(): void
+ {
+ // Already blocked IP
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '10.0.0.1',
+ 'reason' => 'manual',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now(),
+ ]);
+
+ // Critical hit from same IP
+ DB::table('honeypot_hits')->insert([
+ 'ip_address' => '10.0.0.1',
+ 'path' => '/admin',
+ 'severity' => 'critical',
+ 'created_at' => now(),
+ ]);
+
+ $count = $this->service->syncFromHoneypot();
+
+ $this->assertEquals(0, $count);
+ $this->assertDatabaseCount('blocked_ips', 1);
+ }
+
+ public function test_sync_from_honeypot_deduplicates_ips(): void
+ {
+ // Multiple hits from same IP
+ DB::table('honeypot_hits')->insert([
+ ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()],
+ ['ip_address' => '10.0.0.1', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()],
+ ['ip_address' => '10.0.0.1', 'path' => '/phpmyadmin', 'severity' => 'critical', 'created_at' => now()],
+ ]);
+
+ $count = $this->service->syncFromHoneypot();
+
+ $this->assertEquals(1, $count);
+ $this->assertDatabaseCount('blocked_ips', 1);
+ }
+
+ // =========================================================================
+ // Pagination Tests
+ // =========================================================================
+
+ public function test_get_blocklist_paginated_returns_paginator(): void
+ {
+ // Insert multiple blocked IPs
+ for ($i = 1; $i <= 10; $i++) {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => "192.168.1.{$i}",
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ 'blocked_at' => now(),
+ ]);
+ }
+
+ $result = $this->service->getBlocklistPaginated(5);
+
+ $this->assertInstanceOf(LengthAwarePaginator::class, $result);
+ $this->assertEquals(10, $result->total());
+ $this->assertEquals(5, $result->perPage());
+ $this->assertCount(5, $result->items());
+ }
+
+ public function test_get_blocklist_paginated_filters_by_status(): void
+ {
+ DB::table('blocked_ips')->insert([
+ ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()],
+ ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()],
+ ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now()],
+ ]);
+
+ $approved = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_APPROVED);
+ $pending = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_PENDING);
+
+ $this->assertEquals(1, $approved->total());
+ $this->assertEquals(1, $pending->total());
+ }
+
+ public function test_get_blocklist_paginated_orders_by_blocked_at_desc(): void
+ {
+ DB::table('blocked_ips')->insert([
+ ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHours(2)],
+ ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()],
+ ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHour()],
+ ]);
+
+ $result = $this->service->getBlocklistPaginated(10);
+ $items = collect($result->items());
+
+ // Should be ordered most recent first
+ $this->assertEquals('192.168.1.2', $items->first()->ip_address);
+ $this->assertEquals('192.168.1.1', $items->last()->ip_address);
+ }
+
+ public function test_get_pending_returns_array_when_per_page_is_null(): void
+ {
+ DB::table('blocked_ips')->insert([
+ ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()],
+ ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()],
+ ]);
+
+ $result = $this->service->getPending(null);
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+ }
+
+ public function test_get_pending_returns_paginator_when_per_page_provided(): void
+ {
+ DB::table('blocked_ips')->insert([
+ ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()],
+ ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()],
+ ]);
+
+ $result = $this->service->getPending(1);
+
+ $this->assertInstanceOf(LengthAwarePaginator::class, $result);
+ $this->assertEquals(2, $result->total());
+ $this->assertEquals(1, $result->perPage());
+ }
+
+ // =========================================================================
+ // Approval/Rejection Tests
+ // =========================================================================
+
+ public function test_approve_changes_pending_to_approved(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_PENDING,
+ 'blocked_at' => now(),
+ ]);
+
+ $result = $this->service->approve('192.168.1.100');
+
+ $this->assertTrue($result);
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ 'status' => BlocklistService::STATUS_APPROVED,
+ ]);
+ }
+
+ public function test_approve_returns_false_for_non_pending_entry(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED, // Already approved
+ 'blocked_at' => now(),
+ ]);
+
+ $result = $this->service->approve('192.168.1.100');
+
+ $this->assertFalse($result);
+ }
+
+ public function test_approve_returns_false_for_non_existent_entry(): void
+ {
+ $result = $this->service->approve('192.168.1.200');
+
+ $this->assertFalse($result);
+ }
+
+ public function test_approve_clears_cache(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_PENDING,
+ 'blocked_at' => now(),
+ ]);
+
+ Cache::shouldReceive('forget')
+ ->once()
+ ->with('bouncer:blocklist');
+
+ $this->service->approve('192.168.1.100');
+ }
+
+ public function test_reject_changes_pending_to_rejected(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_PENDING,
+ 'blocked_at' => now(),
+ ]);
+
+ $result = $this->service->reject('192.168.1.100');
+
+ $this->assertTrue($result);
+ $this->assertDatabaseHas('blocked_ips', [
+ 'ip_address' => '192.168.1.100',
+ 'status' => BlocklistService::STATUS_REJECTED,
+ ]);
+ }
+
+ public function test_reject_returns_false_for_non_pending_entry(): void
+ {
+ DB::table('blocked_ips')->insert([
+ 'ip_address' => '192.168.1.100',
+ 'reason' => 'test',
+ 'status' => BlocklistService::STATUS_APPROVED, // Not pending
+ 'blocked_at' => now(),
+ ]);
+
+ $result = $this->service->reject('192.168.1.100');
+
+ $this->assertFalse($result);
+ }
+
+ // =========================================================================
+ // Stats Tests
+ // =========================================================================
+
+ public function test_get_stats_returns_complete_statistics(): void
+ {
+ // Insert test data - each row must have same columns
+ DB::table('blocked_ips')->insert([
+ ['ip_address' => '192.168.1.1', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->addDay()],
+ ['ip_address' => '192.168.1.2', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->subDay()], // Expired
+ ['ip_address' => '192.168.1.3', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now(), 'expires_at' => null],
+ ['ip_address' => '192.168.1.4', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now(), 'expires_at' => null],
+ ]);
+
+ Cache::forget('bouncer:blocked_ips_table_exists');
+ $stats = $this->service->getStats();
+
+ $this->assertEquals(4, $stats['total_blocked']);
+ $this->assertEquals(1, $stats['active_blocked']); // Only 1 approved and not expired
+ $this->assertEquals(1, $stats['pending_review']);
+ $this->assertEquals(['manual' => 2, 'honeypot' => 2], $stats['by_reason']);
+ $this->assertEquals([
+ BlocklistService::STATUS_APPROVED => 2,
+ BlocklistService::STATUS_PENDING => 1,
+ BlocklistService::STATUS_REJECTED => 1,
+ ], $stats['by_status']);
+ }
+
+ public function test_get_stats_returns_zeros_when_table_is_empty(): void
+ {
+ Cache::forget('bouncer:blocked_ips_table_exists');
+ $stats = $this->service->getStats();
+
+ $this->assertEquals(0, $stats['total_blocked']);
+ $this->assertEquals(0, $stats['active_blocked']);
+ $this->assertEquals(0, $stats['pending_review']);
+ $this->assertEmpty($stats['by_reason']);
+ $this->assertEmpty($stats['by_status']);
+ }
+
+ // =========================================================================
+ // Cache Tests
+ // =========================================================================
+
+ public function test_clear_cache_removes_cached_blocklist(): void
+ {
+ Cache::shouldReceive('forget')
+ ->once()
+ ->with('bouncer:blocklist');
+
+ $this->service->clearCache();
+ }
+
+ public function test_get_blocklist_uses_cache(): void
+ {
+ $cachedData = ['192.168.1.1' => 'test_reason'];
+
+ Cache::shouldReceive('remember')
+ ->once()
+ ->with('bouncer:blocklist', 300, \Mockery::type('Closure'))
+ ->andReturn($cachedData);
+
+ $result = $this->service->getBlocklist();
+
+ $this->assertEquals($cachedData, $result);
+ }
+
+ // =========================================================================
+ // Status Constants Tests
+ // =========================================================================
+
+ public function test_status_constants_are_defined(): void
+ {
+ $this->assertEquals('pending', BlocklistService::STATUS_PENDING);
+ $this->assertEquals('approved', BlocklistService::STATUS_APPROVED);
+ $this->assertEquals('rejected', BlocklistService::STATUS_REJECTED);
+ }
+}
diff --git a/src/Core/Cdn/Boot.php b/src/Core/Cdn/Boot.php
new file mode 100644
index 0000000..f44fd14
--- /dev/null
+++ b/src/Core/Cdn/Boot.php
@@ -0,0 +1,146 @@
+mergeConfigFrom(__DIR__.'/config.php', 'cdn');
+ $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(\Core\Plug\Storage\StorageManager::class)) {
+ $this->app->singleton(\Core\Plug\Storage\StorageManager::class);
+ }
+
+ // Register legacy services as singletons (for backward compatibility)
+ $this->app->singleton(BunnyCdnService::class);
+ $this->app->singleton(BunnyStorageService::class);
+ $this->app->singleton(StorageUrlResolver::class);
+ $this->app->singleton(FluxCdnService::class);
+ $this->app->singleton(AssetPipeline::class);
+ $this->app->singleton(StorageOffload::class);
+
+ // Register backward compatibility aliases
+ $this->registerBackwardCompatAliases();
+ }
+
+ /**
+ * Bootstrap services.
+ */
+ public function boot(): void
+ {
+ // Register console commands
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ CdnPurge::class,
+ PushAssetsToCdn::class,
+ PushFluxToCdn::class,
+ OffloadMigrateCommand::class,
+ ]);
+ }
+ }
+
+ /**
+ * Register backward compatibility class aliases.
+ *
+ * These allow existing code using old namespaces to continue working
+ * while we migrate to the new Core structure.
+ */
+ protected function registerBackwardCompatAliases(): void
+ {
+ // Services
+ if (! class_exists(\App\Services\BunnyCdnService::class)) {
+ class_alias(BunnyCdnService::class, \App\Services\BunnyCdnService::class);
+ }
+
+ if (! class_exists(\App\Services\Storage\BunnyStorageService::class)) {
+ class_alias(BunnyStorageService::class, \App\Services\Storage\BunnyStorageService::class);
+ }
+
+ if (! class_exists(\App\Services\Storage\StorageUrlResolver::class)) {
+ class_alias(StorageUrlResolver::class, \App\Services\Storage\StorageUrlResolver::class);
+ }
+
+ if (! class_exists(\App\Services\Storage\AssetPipeline::class)) {
+ class_alias(AssetPipeline::class, \App\Services\Storage\AssetPipeline::class);
+ }
+
+ if (! class_exists(\App\Services\Storage\StorageOffload::class)) {
+ class_alias(StorageOffload::class, \App\Services\Storage\StorageOffload::class);
+ }
+
+ if (! class_exists(\App\Services\Cdn\FluxCdnService::class)) {
+ class_alias(FluxCdnService::class, \App\Services\Cdn\FluxCdnService::class);
+ }
+
+ // Crypt
+ if (! class_exists(\App\Services\Crypt\LthnHash::class)) {
+ class_alias(\Core\Crypt\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);
+ }
+
+ // Facades
+ if (! class_exists(\App\Facades\Cdn::class)) {
+ class_alias(\Core\Cdn\Facades\Cdn::class, \App\Facades\Cdn::class);
+ }
+
+ // Traits
+ if (! trait_exists(\App\Traits\HasCdnUrls::class)) {
+ class_alias(\Core\Cdn\Traits\HasCdnUrls::class, \App\Traits\HasCdnUrls::class);
+ }
+
+ // Middleware
+ if (! class_exists(\App\Http\Middleware\RewriteOffloadedUrls::class)) {
+ class_alias(\Core\Cdn\Middleware\RewriteOffloadedUrls::class, \App\Http\Middleware\RewriteOffloadedUrls::class);
+ }
+
+ // Jobs
+ if (! class_exists(\App\Jobs\PushAssetToCdn::class)) {
+ class_alias(\Core\Cdn\Jobs\PushAssetToCdn::class, \App\Jobs\PushAssetToCdn::class);
+ }
+ }
+}
diff --git a/src/Core/Cdn/Console/CdnPurge.php b/src/Core/Cdn/Console/CdnPurge.php
new file mode 100644
index 0000000..b947999
--- /dev/null
+++ b/src/Core/Cdn/Console/CdnPurge.php
@@ -0,0 +1,322 @@
+purger = new \Core\Plug\Cdn\Bunny\Purge;
+ }
+ }
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ if ($this->purger === null) {
+ $this->error('CDN Purge requires Core\Plug\Cdn\Bunny\Purge class. Plug module not installed.');
+
+ return self::FAILURE;
+ }
+
+ $workspaceArg = $this->argument('workspace');
+ $urls = $this->option('url');
+ $tag = $this->option('tag');
+ $everything = $this->option('everything');
+ $dryRun = $this->option('dry-run');
+
+ if ($dryRun) {
+ $this->info('Dry run mode - no changes will be made');
+ $this->newLine();
+ }
+
+ // Check for mutually exclusive options
+ $optionCount = ($everything ? 1 : 0) + (! empty($urls) ? 1 : 0) + (! empty($tag) ? 1 : 0) + (! empty($workspaceArg) ? 1 : 0);
+ if ($optionCount > 1 && $everything) {
+ $this->error('Cannot use --everything with other options');
+
+ return self::FAILURE;
+ }
+
+ // Purge everything
+ if ($everything) {
+ return $this->purgeEverything($dryRun);
+ }
+
+ // Purge specific URLs
+ if (! empty($urls)) {
+ return $this->purgeUrls($urls, $dryRun);
+ }
+
+ // Purge by tag
+ if (! empty($tag)) {
+ return $this->purgeByTag($tag, $dryRun);
+ }
+
+ // 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());
+ }
+ $workspaceArg = $this->choice(
+ 'What would you like to purge?',
+ $workspaceOptions,
+ 0
+ );
+
+ if ($workspaceArg === 'Select specific URLs') {
+ $urlInput = $this->ask('Enter URL(s) to purge (comma-separated)');
+ $urls = array_map('trim', explode(',', $urlInput));
+
+ return $this->purgeUrls($urls, $dryRun);
+ }
+ }
+
+ if ($workspaceArg === 'all') {
+ return $this->purgeAllWorkspaces($dryRun);
+ }
+
+ return $this->purgeWorkspace($workspaceArg, $dryRun);
+ }
+
+ protected function purgeEverything(bool $dryRun): int
+ {
+ if (! $dryRun && ! $this->confirm('Are you sure you want to purge the ENTIRE CDN cache? This will affect all content.', false)) {
+ $this->info('Aborted');
+
+ return self::SUCCESS;
+ }
+
+ $this->warn('Purging entire CDN cache...');
+
+ if ($dryRun) {
+ $this->info('Would purge: entire CDN cache');
+
+ return self::SUCCESS;
+ }
+
+ try {
+ $result = $this->purger->all();
+
+ if ($result->isOk()) {
+ $this->info('CDN cache purged successfully');
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Failed to purge CDN cache: '.$result->message());
+
+ return self::FAILURE;
+ } catch (\Exception $e) {
+ $this->error("Purge failed: {$e->getMessage()}");
+
+ return self::FAILURE;
+ }
+ }
+
+ protected function purgeUrls(array $urls, bool $dryRun): int
+ {
+ $this->info('Purging '.count($urls).' URL(s)...');
+
+ foreach ($urls as $url) {
+ $this->line(" - {$url}");
+ }
+
+ if ($dryRun) {
+ return self::SUCCESS;
+ }
+
+ try {
+ $result = $this->purger->urls($urls);
+
+ if ($result->isOk()) {
+ $this->newLine();
+ $this->info('URLs purged successfully');
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Failed to purge URLs: '.$result->message());
+
+ return self::FAILURE;
+ } catch (\Exception $e) {
+ $this->error("Purge failed: {$e->getMessage()}");
+
+ return self::FAILURE;
+ }
+ }
+
+ protected function purgeByTag(string $tag, bool $dryRun): int
+ {
+ $this->info("Purging cache tag: {$tag}");
+
+ if ($dryRun) {
+ $this->info("Would purge: all content with tag '{$tag}'");
+
+ return self::SUCCESS;
+ }
+
+ try {
+ $result = $this->purger->tag($tag);
+
+ if ($result->isOk()) {
+ $this->info('Cache tag purged successfully');
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Failed to purge cache tag: '.$result->message());
+
+ return self::FAILURE;
+ } catch (\Exception $e) {
+ $this->error("Purge failed: {$e->getMessage()}");
+
+ return self::FAILURE;
+ }
+ }
+
+ protected function purgeAllWorkspaces(bool $dryRun): int
+ {
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $this->error('Workspace purge requires Tenant module to be installed.');
+
+ return self::FAILURE;
+ }
+
+ $workspaces = \Core\Tenant\Models\Workspace::all();
+
+ if ($workspaces->isEmpty()) {
+ $this->error('No workspaces found');
+
+ return self::FAILURE;
+ }
+
+ $this->info("Purging {$workspaces->count()} workspaces...");
+ $this->newLine();
+
+ $success = true;
+
+ foreach ($workspaces as $workspace) {
+ $this->line("Workspace: {$workspace->slug} ");
+
+ if ($dryRun) {
+ $this->line(" Would purge: workspace-{$workspace->uuid}");
+
+ continue;
+ }
+
+ try {
+ $result = $this->purger->workspace($workspace->uuid);
+
+ if ($result->isOk()) {
+ $this->line(' Purged>');
+ } else {
+ $this->line(' Failed: '.$result->message().'>');
+ $success = false;
+ }
+ } catch (\Exception $e) {
+ $this->line(" Error: {$e->getMessage()}>");
+ $success = false;
+ }
+ }
+
+ $this->newLine();
+
+ if ($success) {
+ $this->info('All workspaces purged successfully');
+
+ return self::SUCCESS;
+ }
+
+ $this->warn('Some workspaces failed to purge');
+
+ return self::FAILURE;
+ }
+
+ protected function purgeWorkspace(string $slug, bool $dryRun): int
+ {
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $this->error('Workspace purge requires Tenant module to be installed.');
+
+ return self::FAILURE;
+ }
+
+ $workspace = \Core\Tenant\Models\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}"));
+
+ return self::FAILURE;
+ }
+
+ $this->info("Purging workspace: {$workspace->slug}");
+
+ if ($dryRun) {
+ $this->line("Would purge: workspace-{$workspace->uuid}");
+
+ return self::SUCCESS;
+ }
+
+ try {
+ $result = $this->purger->workspace($workspace->uuid);
+
+ if ($result->isOk()) {
+ $this->info('Workspace cache purged successfully');
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Failed to purge workspace cache: '.$result->message());
+
+ return self::FAILURE;
+ } catch (\Exception $e) {
+ $this->error("Purge failed: {$e->getMessage()}");
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/src/Core/Cdn/Console/OffloadMigrateCommand.php b/src/Core/Cdn/Console/OffloadMigrateCommand.php
new file mode 100644
index 0000000..bc4d102
--- /dev/null
+++ b/src/Core/Cdn/Console/OffloadMigrateCommand.php
@@ -0,0 +1,214 @@
+offloadService = $offloadService;
+
+ if (! $this->offloadService->isEnabled()) {
+ $this->error('Storage offload is not enabled in configuration.');
+ $this->info('Set STORAGE_OFFLOAD_ENABLED=true in your .env file.');
+
+ return self::FAILURE;
+ }
+
+ // Determine path to scan
+ $path = $this->argument('path') ?? storage_path('app/public');
+ $category = $this->option('category');
+ $dryRun = $this->option('dry-run');
+ $onlyMissing = $this->option('only-missing');
+
+ if (! is_dir($path)) {
+ $this->error("Directory not found: {$path}");
+
+ return self::FAILURE;
+ }
+
+ $this->info("Scanning directory: {$path}");
+ $this->info("Category: {$category}");
+ $this->info("Disk: {$this->offloadService->getDiskName()}");
+
+ if ($dryRun) {
+ $this->warn('DRY RUN MODE - No files will be offloaded');
+ }
+
+ $this->line('');
+
+ // Scan for files
+ $files = $this->scanDirectory($path);
+
+ if (empty($files)) {
+ $this->info('No eligible files found.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info('Found '.count($files).' file(s) to process.');
+
+ // Filter already offloaded files if requested
+ if ($onlyMissing) {
+ $files = array_filter($files, function ($file) {
+ return ! $this->offloadService->isOffloaded($file);
+ });
+
+ if (empty($files)) {
+ $this->info('All files are already offloaded.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info('Found '.count($files).' file(s) not yet offloaded.');
+ }
+
+ // Calculate total size
+ $totalSize = array_sum(array_map('filesize', $files));
+ $this->info('Total size: '.$this->formatBytes($totalSize));
+ $this->line('');
+
+ // Confirmation
+ if (! $dryRun && ! $this->option('force')) {
+ if (! $this->confirm('Proceed with offloading?')) {
+ $this->info('Cancelled.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ // Process files
+ $processed = 0;
+ $failed = 0;
+ $skipped = 0;
+
+ $this->withProgressBar($files, function ($file) use ($category, $dryRun, &$processed, &$failed, &$skipped) {
+ // Check if already offloaded
+ if ($this->offloadService->isOffloaded($file)) {
+ $skipped++;
+
+ return;
+ }
+
+ if ($dryRun) {
+ $processed++;
+
+ return;
+ }
+
+ // Attempt to offload
+ $result = $this->offloadService->upload($file, null, $category);
+
+ if ($result) {
+ $processed++;
+ } else {
+ $failed++;
+ }
+ });
+
+ $this->newLine(2);
+
+ // Summary
+ $this->info('Migration complete!');
+ $this->table(
+ ['Status', 'Count'],
+ [
+ ['Processed', $processed],
+ ['Failed', $failed],
+ ['Skipped', $skipped],
+ ['Total', count($files)],
+ ]
+ );
+
+ if ($failed > 0) {
+ $this->warn('Some files failed to offload. Check logs for details.');
+
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Scan directory recursively for eligible files.
+ */
+ protected function scanDirectory(string $path): array
+ {
+ $files = [];
+ $allowedExtensions = config('offload.allowed_extensions', []);
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ($iterator as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $filePath = $file->getPathname();
+ $extension = strtolower($file->getExtension());
+
+ // Skip if not in allowed extensions list (if configured)
+ if (! empty($allowedExtensions) && ! in_array($extension, $allowedExtensions)) {
+ continue;
+ }
+
+ // Skip if exceeds max file size
+ $maxSize = config('offload.max_file_size', 100 * 1024 * 1024);
+ if ($file->getSize() > $maxSize) {
+ continue;
+ }
+
+ $files[] = $filePath;
+ }
+
+ return $files;
+ }
+
+ /**
+ * Format bytes to human-readable format.
+ */
+ protected function formatBytes(int $bytes): string
+ {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
+ $power = min($power, count($units) - 1);
+
+ return round($bytes / (1024 ** $power), 2).' '.$units[$power];
+ }
+}
diff --git a/src/Core/Cdn/Console/PushAssetsToCdn.php b/src/Core/Cdn/Console/PushAssetsToCdn.php
new file mode 100644
index 0000000..3e2e931
--- /dev/null
+++ b/src/Core/Cdn/Console/PushAssetsToCdn.php
@@ -0,0 +1,197 @@
+error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.');
+
+ return self::FAILURE;
+ }
+
+ $this->cdn = $cdn;
+ $this->dryRun = $this->option('dry-run');
+
+ // Create vBucket for workspace isolation
+ $domain = $this->option('domain');
+ $this->vbucket = \Core\Plug\Storage\Bunny\VBucket::public($domain);
+
+ $pushFlux = $this->option('flux');
+ $pushFontawesome = $this->option('fontawesome');
+ $pushJs = $this->option('js');
+ $pushAll = $this->option('all') || (! $pushFlux && ! $pushFontawesome && ! $pushJs);
+
+ $this->info("Pushing assets to CDN storage zone for {$domain}...");
+ $this->line("vBucket: {$this->vbucket->id()}");
+ $this->newLine();
+
+ if ($pushAll || $pushFlux) {
+ $this->pushFluxAssets($flux);
+ }
+
+ if ($pushAll || $pushFontawesome) {
+ $this->pushFontAwesomeAssets();
+ }
+
+ if ($pushAll || $pushJs) {
+ $this->pushJsAssets();
+ }
+
+ $this->newLine();
+
+ if ($this->dryRun) {
+ $this->info("Dry run complete. Would upload {$this->uploadCount} files.");
+ } else {
+ $this->info("Upload complete. {$this->uploadCount} files uploaded, {$this->failCount} failed.");
+ $this->line('CDN URL: '.config('cdn.urls.cdn'));
+ }
+
+ return $this->failCount > 0 ? self::FAILURE : self::SUCCESS;
+ }
+
+ protected function pushFluxAssets(FluxCdnService $flux): void
+ {
+ $this->components->info('Flux UI assets');
+
+ $assets = $flux->getCdnAssetPaths();
+
+ foreach ($assets as $sourcePath => $cdnPath) {
+ $this->uploadFile($sourcePath, $cdnPath);
+ }
+ }
+
+ protected function pushFontAwesomeAssets(): void
+ {
+ $this->components->info('Font Awesome assets');
+
+ $basePath = public_path('vendor/fontawesome');
+
+ if (! File::isDirectory($basePath)) {
+ $this->warn(' Font Awesome directory not found at public/vendor/fontawesome');
+
+ return;
+ }
+
+ // Push CSS files
+ $cssPath = "{$basePath}/css";
+ if (File::isDirectory($cssPath)) {
+ foreach (File::files($cssPath) as $file) {
+ $cdnPath = 'vendor/fontawesome/css/'.$file->getFilename();
+ $this->uploadFile($file->getPathname(), $cdnPath);
+ }
+ }
+
+ // Push webfonts
+ $webfontsPath = "{$basePath}/webfonts";
+ if (File::isDirectory($webfontsPath)) {
+ foreach (File::files($webfontsPath) as $file) {
+ $cdnPath = 'vendor/fontawesome/webfonts/'.$file->getFilename();
+ $this->uploadFile($file->getPathname(), $cdnPath);
+ }
+ }
+ }
+
+ protected function pushJsAssets(): void
+ {
+ $this->components->info('JavaScript assets');
+
+ $jsPath = public_path('js');
+
+ if (! File::isDirectory($jsPath)) {
+ $this->warn(' JavaScript directory not found at public/js');
+
+ return;
+ }
+
+ foreach (File::files($jsPath) as $file) {
+ if ($file->getExtension() === 'js') {
+ $cdnPath = 'js/'.$file->getFilename();
+ $this->uploadFile($file->getPathname(), $cdnPath);
+ }
+ }
+ }
+
+ protected function uploadFile(string $sourcePath, string $cdnPath): void
+ {
+ if (! file_exists($sourcePath)) {
+ $this->warn(" ✗ Source not found: {$sourcePath}");
+ $this->failCount++;
+
+ return;
+ }
+
+ $size = $this->formatBytes(filesize($sourcePath));
+
+ if ($this->dryRun) {
+ $this->line(" [DRY-RUN] {$cdnPath} ({$size})");
+ $this->uploadCount++;
+
+ return;
+ }
+
+ // Push directly to CDN storage zone via vBucket (workspace-isolated)
+ $contents = file_get_contents($sourcePath);
+ $result = $this->vbucket->putContents($cdnPath, $contents);
+
+ if ($result->isOk()) {
+ $this->line(" ✓ {$cdnPath} ({$size})");
+ $this->uploadCount++;
+ } else {
+ $this->error(" ✗ Failed: {$cdnPath}");
+ $this->failCount++;
+ }
+ }
+
+ protected function formatBytes(int $bytes): string
+ {
+ if ($bytes >= 1048576) {
+ return round($bytes / 1048576, 2).' MB';
+ }
+
+ if ($bytes >= 1024) {
+ return round($bytes / 1024, 2).' KB';
+ }
+
+ return $bytes.' bytes';
+ }
+}
diff --git a/src/Core/Cdn/Console/PushFluxToCdn.php b/src/Core/Cdn/Console/PushFluxToCdn.php
new file mode 100644
index 0000000..51db58a
--- /dev/null
+++ b/src/Core/Cdn/Console/PushFluxToCdn.php
@@ -0,0 +1,86 @@
+info('Pushing Flux assets to CDN...');
+
+ $assets = $flux->getCdnAssetPaths();
+
+ if (empty($assets)) {
+ $this->warn('No Flux assets found to push.');
+
+ return self::SUCCESS;
+ }
+
+ $dryRun = $this->option('dry-run');
+
+ foreach ($assets as $sourcePath => $cdnPath) {
+ if (! file_exists($sourcePath)) {
+ $this->warn("Source file not found: {$sourcePath}");
+
+ continue;
+ }
+
+ $size = $this->formatBytes(filesize($sourcePath));
+
+ if ($dryRun) {
+ $this->line(" [DRY-RUN] Would upload: {$cdnPath} ({$size})");
+
+ continue;
+ }
+
+ $this->line(" Uploading: {$cdnPath} ({$size})");
+
+ $contents = file_get_contents($sourcePath);
+ $success = $cdn->storePublic($cdnPath, $contents, pushToCdn: true);
+
+ if ($success) {
+ $this->info(' ✓ Uploaded to CDN');
+ } else {
+ $this->error(' ✗ Failed to upload');
+ }
+ }
+
+ if (! $dryRun) {
+ $this->newLine();
+ $this->info('Flux assets pushed to CDN successfully.');
+ $this->line('CDN URL: '.config('cdn.urls.cdn').'/flux/');
+ }
+
+ return self::SUCCESS;
+ }
+
+ protected function formatBytes(int $bytes): string
+ {
+ if ($bytes >= 1048576) {
+ return round($bytes / 1048576, 2).' MB';
+ }
+
+ if ($bytes >= 1024) {
+ return round($bytes / 1024, 2).' KB';
+ }
+
+ return $bytes.' bytes';
+ }
+}
diff --git a/src/Core/Cdn/Facades/Cdn.php b/src/Core/Cdn/Facades/Cdn.php
new file mode 100644
index 0000000..88672ef
--- /dev/null
+++ b/src/Core/Cdn/Facades/Cdn.php
@@ -0,0 +1,53 @@
+onQueue(config('cdn.pipeline.queue', 'cdn'));
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @param object|null $storage StorageManager instance when Plug module available
+ */
+ public function handle(?object $storage = null): void
+ {
+ if (! class_exists(\Core\Plug\Storage\StorageManager::class)) {
+ Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed');
+
+ return;
+ }
+
+ // Resolve from container if not injected
+ if ($storage === null) {
+ $storage = app(\Core\Plug\Storage\StorageManager::class);
+ }
+
+ if (! config('cdn.bunny.push_enabled', false)) {
+ Log::debug('PushAssetToCdn: Push disabled, skipping', [
+ 'disk' => $this->disk,
+ 'path' => $this->path,
+ ]);
+
+ return;
+ }
+
+ $uploader = $storage->zone($this->zone)->upload();
+
+ if (! $uploader->isConfigured()) {
+ Log::warning('PushAssetToCdn: CDN storage not configured', [
+ 'zone' => $this->zone,
+ ]);
+
+ return;
+ }
+
+ // Get contents from origin disk
+ $sourceDisk = Storage::disk($this->disk);
+ if (! $sourceDisk->exists($this->path)) {
+ Log::warning('PushAssetToCdn: Source file not found on disk', [
+ 'disk' => $this->disk,
+ 'path' => $this->path,
+ ]);
+
+ return;
+ }
+
+ $contents = $sourceDisk->get($this->path);
+ $result = $uploader->contents($this->path, $contents);
+
+ if ($result->hasError()) {
+ Log::error('PushAssetToCdn: Failed to push asset', [
+ 'disk' => $this->disk,
+ 'path' => $this->path,
+ 'zone' => $this->zone,
+ 'error' => $result->message(),
+ ]);
+
+ $this->fail(new \Exception("Failed to push {$this->path} to CDN zone {$this->zone}"));
+ }
+
+ Log::info('PushAssetToCdn: Asset pushed successfully', [
+ 'disk' => $this->disk,
+ 'path' => $this->path,
+ 'zone' => $this->zone,
+ ]);
+ }
+
+ /**
+ * Get the tags that should be assigned to the job.
+ *
+ * @return array
+ */
+ public function tags(): array
+ {
+ return [
+ 'cdn',
+ 'push',
+ "zone:{$this->zone}",
+ "path:{$this->path}",
+ ];
+ }
+
+ /**
+ * Determine if the job should be unique.
+ */
+ public function uniqueId(): string
+ {
+ return "{$this->zone}:{$this->path}";
+ }
+
+ /**
+ * The unique ID of the job.
+ */
+ public function uniqueFor(): int
+ {
+ return 300; // 5 minutes
+ }
+}
diff --git a/src/Core/Cdn/Middleware/LocalCdnMiddleware.php b/src/Core/Cdn/Middleware/LocalCdnMiddleware.php
new file mode 100644
index 0000000..e9d3ff8
--- /dev/null
+++ b/src/Core/Cdn/Middleware/LocalCdnMiddleware.php
@@ -0,0 +1,143 @@
+ normal app
+ * cdn.core.test -> same app, but with CDN headers
+ */
+class LocalCdnMiddleware
+{
+ /**
+ * Handle an incoming request.
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ // Check if this is a CDN subdomain request
+ if (! $this->isCdnSubdomain($request)) {
+ return $next($request);
+ }
+
+ // Process the request
+ $response = $next($request);
+
+ // Add CDN headers to the response
+ $this->addCdnHeaders($response, $request);
+
+ return $response;
+ }
+
+ /**
+ * Check if request is to the CDN subdomain.
+ */
+ protected function isCdnSubdomain(Request $request): bool
+ {
+ $host = $request->getHost();
+ $cdnSubdomain = config('core.cdn.subdomain', 'cdn');
+ $baseDomain = config('core.domain.base', 'core.test');
+
+ // Check for cdn.{domain} pattern
+ return str_starts_with($host, "{$cdnSubdomain}.");
+ }
+
+ /**
+ * Add CDN-appropriate headers to the response.
+ */
+ protected function addCdnHeaders(Response $response, Request $request): void
+ {
+ // Skip if response isn't successful
+ if (! $response->isSuccessful()) {
+ return;
+ }
+
+ // Get cache settings from config
+ $maxAge = config('core.cdn.cache_max_age', 31536000); // 1 year
+ $immutable = config('core.cdn.cache_immutable', true);
+
+ // Build Cache-Control header
+ $cacheControl = "public, max-age={$maxAge}";
+ if ($immutable) {
+ $cacheControl .= ', immutable';
+ }
+
+ $response->headers->set('Cache-Control', $cacheControl);
+
+ // Add ETag if possible
+ if ($response instanceof BinaryFileResponse) {
+ $file = $response->getFile();
+ if ($file && $file->isFile()) {
+ $etag = md5($file->getMTime().$file->getSize());
+ $response->headers->set('ETag', "\"{$etag}\"");
+ }
+ }
+
+ // Vary on Accept-Encoding for compressed responses
+ $response->headers->set('Vary', 'Accept-Encoding');
+
+ // Add timing header for debugging
+ $response->headers->set('X-CDN-Cache', 'local');
+
+ // Set Content-Type headers for common static files
+ $this->setContentTypeHeaders($response, $request);
+ }
+
+ /**
+ * Set appropriate Content-Type for static assets.
+ */
+ protected function setContentTypeHeaders(Response $response, Request $request): void
+ {
+ $path = $request->getPathInfo();
+ $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ $mimeTypes = [
+ 'js' => 'application/javascript; charset=utf-8',
+ 'mjs' => 'application/javascript; charset=utf-8',
+ 'css' => 'text/css; charset=utf-8',
+ 'json' => 'application/json; charset=utf-8',
+ 'svg' => 'image/svg+xml',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'eot' => 'application/vnd.ms-fontobject',
+ 'ico' => 'image/x-icon',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ 'avif' => 'image/avif',
+ 'mp4' => 'video/mp4',
+ 'webm' => 'video/webm',
+ 'mp3' => 'audio/mpeg',
+ 'ogg' => 'audio/ogg',
+ 'xml' => 'application/xml; charset=utf-8',
+ 'txt' => 'text/plain; charset=utf-8',
+ 'map' => 'application/json; charset=utf-8',
+ ];
+
+ if (isset($mimeTypes[$extension])) {
+ $response->headers->set('Content-Type', $mimeTypes[$extension]);
+ }
+ }
+}
diff --git a/src/Core/Cdn/Middleware/RewriteOffloadedUrls.php b/src/Core/Cdn/Middleware/RewriteOffloadedUrls.php
new file mode 100644
index 0000000..5110d2d
--- /dev/null
+++ b/src/Core/Cdn/Middleware/RewriteOffloadedUrls.php
@@ -0,0 +1,168 @@
+offloadService = $offloadService;
+ }
+
+ /**
+ * Handle an incoming request.
+ *
+ * Rewrites URLs in JSON responses to point to offloaded storage.
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ $response = $next($request);
+
+ // Only process JSON responses
+ if (! $this->shouldProcess($response)) {
+ return $response;
+ }
+
+ // Get response content
+ $content = $response->getContent();
+ if (empty($content)) {
+ return $response;
+ }
+
+ // Decode JSON
+ $data = json_decode($content, true);
+ if ($data === null) {
+ return $response;
+ }
+
+ // Rewrite URLs in the data
+ $rewritten = $this->rewriteUrls($data);
+
+ // Update response
+ $response->setContent(json_encode($rewritten));
+
+ return $response;
+ }
+
+ /**
+ * Check if response should be processed.
+ */
+ protected function shouldProcess(Response $response): bool
+ {
+ // Only process successful responses
+ if (! $response->isSuccessful()) {
+ return false;
+ }
+
+ // Check content type
+ $contentType = $response->headers->get('Content-Type', '');
+
+ return str_contains($contentType, 'application/json');
+ }
+
+ /**
+ * Recursively rewrite URLs in data structure.
+ */
+ protected function rewriteUrls(mixed $data): mixed
+ {
+ if (is_array($data)) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $this->rewriteUrls($value);
+ }
+
+ return $data;
+ }
+
+ if (is_string($data)) {
+ return $this->rewriteUrl($data);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Rewrite a single URL if it matches a local storage path.
+ */
+ protected function rewriteUrl(string $value): string
+ {
+ // Only process strings that look like URLs or paths
+ if (! $this->looksLikeStoragePath($value)) {
+ return $value;
+ }
+
+ // Extract local path from URL
+ $localPath = $this->extractLocalPath($value);
+ if (! $localPath) {
+ return $value;
+ }
+
+ // Check if this path has been offloaded
+ $offloadedUrl = $this->offloadService->url($localPath);
+ if ($offloadedUrl) {
+ return $offloadedUrl;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Check if a string looks like a storage path.
+ */
+ protected function looksLikeStoragePath(string $value): bool
+ {
+ // Check for /storage/ in the path
+ if (str_contains($value, '/storage/')) {
+ return true;
+ }
+
+ // Check for storage_path pattern
+ if (preg_match('#/app/(public|private)/#', $value)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Extract local file path from URL.
+ */
+ protected function extractLocalPath(string $url): ?string
+ {
+ // Handle /storage/ URLs (symlinked public storage)
+ if (str_contains($url, '/storage/')) {
+ $parts = explode('/storage/', $url, 2);
+ if (count($parts) === 2) {
+ return storage_path('app/public/'.$parts[1]);
+ }
+ }
+
+ // Handle absolute paths
+ if (str_starts_with($url, storage_path())) {
+ return $url;
+ }
+
+ return null;
+ }
+}
diff --git a/src/Core/Cdn/Models/StorageOffload.php b/src/Core/Cdn/Models/StorageOffload.php
new file mode 100644
index 0000000..3de7912
--- /dev/null
+++ b/src/Core/Cdn/Models/StorageOffload.php
@@ -0,0 +1,161 @@
+ 'array',
+ 'file_size' => 'integer',
+ 'offloaded_at' => 'datetime',
+ ];
+
+ /**
+ * Get the category.
+ */
+ public function getCategory(): ?string
+ {
+ return $this->category;
+ }
+
+ /**
+ * Get metadata value by key.
+ */
+ public function getMetadata(?string $key = null): mixed
+ {
+ if ($key === null) {
+ return $this->metadata;
+ }
+
+ return $this->metadata[$key] ?? null;
+ }
+
+ /**
+ * Get the original filename from metadata.
+ */
+ public function getOriginalName(): ?string
+ {
+ return $this->getMetadata('original_name');
+ }
+
+ /**
+ * Check if this offload is for a specific category.
+ */
+ public function isCategory(string $category): bool
+ {
+ return $this->getCategory() === $category;
+ }
+
+ /**
+ * Check if the file is an image.
+ */
+ public function isImage(): bool
+ {
+ return $this->mime_type && str_starts_with($this->mime_type, 'image/');
+ }
+
+ /**
+ * Check if the file is a video.
+ */
+ public function isVideo(): bool
+ {
+ return $this->mime_type && str_starts_with($this->mime_type, 'video/');
+ }
+
+ /**
+ * Check if the file is audio.
+ */
+ public function isAudio(): bool
+ {
+ return $this->mime_type && str_starts_with($this->mime_type, 'audio/');
+ }
+
+ /**
+ * Scope to filter by category.
+ */
+ public function scopeCategory($query, string $category)
+ {
+ return $query->where('category', $category);
+ }
+
+ /**
+ * Alias for scopeCategory - filter by category.
+ */
+ public function scopeInCategory($query, string $category)
+ {
+ return $this->scopeCategory($query, $category);
+ }
+
+ /**
+ * Scope to filter by disk.
+ */
+ public function scopeDisk($query, string $disk)
+ {
+ return $query->where('disk', $disk);
+ }
+
+ /**
+ * Alias for scopeDisk - filter by disk.
+ */
+ public function scopeForDisk($query, string $disk)
+ {
+ return $this->scopeDisk($query, $disk);
+ }
+
+ /**
+ * Get human-readable file size.
+ */
+ public function getFileSizeHumanAttribute(): string
+ {
+ $bytes = $this->file_size ?? 0;
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
+ $power = min($power, count($units) - 1);
+
+ return round($bytes / (1024 ** $power), 2).' '.$units[$power];
+ }
+}
diff --git a/src/Core/Cdn/Services/AssetPipeline.php b/src/Core/Cdn/Services/AssetPipeline.php
new file mode 100644
index 0000000..dd3944a
--- /dev/null
+++ b/src/Core/Cdn/Services/AssetPipeline.php
@@ -0,0 +1,395 @@
+ private bucket (optional, for processing)
+ * 2. Process (resize, optimize, etc.) -> handled by caller
+ * 3. Store processed -> public bucket
+ * 4. Push to CDN storage zone
+ *
+ * Categories define path prefixes:
+ * - media: General media uploads
+ * - social: Social media assets
+ * - page: Page builder assets
+ * - avatar: User/workspace avatars
+ * - content: ContentMedia
+ * - static: Static assets
+ * - widget: Widget assets
+ *
+ * ## Methods
+ *
+ * | Method | Returns | Description |
+ * |--------|---------|-------------|
+ * | `store()` | `array` | Process and store an uploaded file to public bucket |
+ * | `storeContents()` | `array` | Store raw content (string/stream) to public bucket |
+ * | `storePrivate()` | `array` | Store to private bucket for DRM/gated content |
+ * | `copy()` | `array` | Copy file between buckets |
+ * | `delete()` | `bool` | Delete an asset from storage and CDN |
+ * | `deleteMany()` | `array` | Delete multiple assets |
+ * | `urls()` | `array` | Get CDN and origin URLs for a path |
+ * | `exists()` | `bool` | Check if a file exists in storage |
+ * | `size()` | `int\|null` | Get file size in bytes |
+ * | `mimeType()` | `string\|null` | Get file MIME type |
+ */
+class AssetPipeline
+{
+ protected StorageUrlResolver $urlResolver;
+
+ /**
+ * Storage manager instance (Core\Plug\Storage\StorageManager when available).
+ */
+ protected ?object $storage = null;
+
+ public function __construct(StorageUrlResolver $urlResolver, ?object $storage = null)
+ {
+ $this->urlResolver = $urlResolver;
+ $this->storage = $storage;
+ }
+
+ /**
+ * Process and store an uploaded file.
+ *
+ * @param UploadedFile $file The uploaded file
+ * @param string $category Category key (media, social, page, etc.)
+ * @param string|null $filename Custom filename (auto-generated if null)
+ * @param array $options Additional options (workspace_id, user_id, etc.)
+ * @return array{path: string, cdn_url: string, origin_url: string, size: int, mime: string}
+ */
+ public function store(UploadedFile $file, string $category, ?string $filename = null, array $options = []): array
+ {
+ $filename = $filename ?? $this->generateFilename($file);
+ $path = $this->buildPath($category, $filename, $options);
+
+ // Store to public bucket
+ $stored = $this->urlResolver->publicDisk()->putFileAs(
+ dirname($path),
+ $file,
+ basename($path)
+ );
+
+ if (! $stored) {
+ throw new \RuntimeException("Failed to store file at: {$path}");
+ }
+
+ // Queue CDN push if enabled
+ $this->queueCdnPush('hetzner-public', $path, 'public');
+
+ return [
+ 'path' => $path,
+ 'cdn_url' => $this->urlResolver->cdn($path),
+ 'origin_url' => $this->urlResolver->origin($path),
+ 'size' => $file->getSize(),
+ 'mime' => $file->getMimeType(),
+ ];
+ }
+
+ /**
+ * Store raw content (string or stream).
+ *
+ * @param string|resource $contents File contents
+ * @param string $category Category key
+ * @param string $filename Filename with extension
+ * @param array $options Additional options
+ * @return array{path: string, cdn_url: string, origin_url: string}
+ */
+ public function storeContents($contents, string $category, string $filename, array $options = []): array
+ {
+ $path = $this->buildPath($category, $filename, $options);
+
+ $stored = $this->urlResolver->publicDisk()->put($path, $contents);
+
+ if (! $stored) {
+ throw new \RuntimeException("Failed to store content at: {$path}");
+ }
+
+ $this->queueCdnPush('hetzner-public', $path, 'public');
+
+ return [
+ 'path' => $path,
+ 'cdn_url' => $this->urlResolver->cdn($path),
+ 'origin_url' => $this->urlResolver->origin($path),
+ ];
+ }
+
+ /**
+ * Store to private bucket (for DRM/gated content).
+ *
+ * @param UploadedFile|string|resource $content File or contents
+ * @param string $category Category key
+ * @param string|null $filename Filename (required for non-UploadedFile)
+ * @param array $options Additional options
+ * @return array{path: string, private_url: string}
+ */
+ public function storePrivate($content, string $category, ?string $filename = null, array $options = []): array
+ {
+ if ($content instanceof UploadedFile) {
+ $filename = $filename ?? $this->generateFilename($content);
+ $path = $this->buildPath($category, $filename, $options);
+
+ $stored = $this->urlResolver->privateDisk()->putFileAs(
+ dirname($path),
+ $content,
+ basename($path)
+ );
+ } else {
+ if (! $filename) {
+ throw new \InvalidArgumentException('Filename required for non-UploadedFile content');
+ }
+
+ $path = $this->buildPath($category, $filename, $options);
+ $stored = $this->urlResolver->privateDisk()->put($path, $content);
+ }
+
+ if (! $stored) {
+ throw new \RuntimeException("Failed to store private content at: {$path}");
+ }
+
+ $this->queueCdnPush('hetzner-private', $path, 'private');
+
+ return [
+ 'path' => $path,
+ 'private_url' => $this->urlResolver->private($path),
+ ];
+ }
+
+ /**
+ * Copy an existing file from one bucket to another.
+ *
+ * @param string $sourcePath Source path
+ * @param string $sourceBucket Source bucket ('public' or 'private')
+ * @param string $destBucket Destination bucket ('public' or 'private')
+ * @param string|null $destPath Destination path (same as source if null)
+ * @return array{path: string, bucket: string}
+ *
+ * @throws \RuntimeException If source file not found or copy fails
+ */
+ public function copy(string $sourcePath, string $sourceBucket, string $destBucket, ?string $destPath = null): array
+ {
+ $sourceDisk = $sourceBucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ $destDisk = $destBucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ $destPath = $destPath ?? $sourcePath;
+
+ $contents = $sourceDisk->get($sourcePath);
+
+ if ($contents === null) {
+ throw new \RuntimeException("Source file not found: {$sourcePath}");
+ }
+
+ $stored = $destDisk->put($destPath, $contents);
+
+ if (! $stored) {
+ throw new \RuntimeException("Failed to copy to: {$destPath}");
+ }
+
+ $hetznerDisk = $destBucket === 'private' ? 'hetzner-private' : 'hetzner-public';
+ $this->queueCdnPush($hetznerDisk, $destPath, $destBucket);
+
+ return [
+ 'path' => $destPath,
+ 'bucket' => $destBucket,
+ ];
+ }
+
+ /**
+ * Delete an asset from storage and CDN.
+ *
+ * @param string $path File path
+ * @param string $bucket 'public' or 'private'
+ * @return bool True if deletion was successful
+ */
+ public function delete(string $path, string $bucket = 'public'): bool
+ {
+ return $this->urlResolver->deleteAsset($path, $bucket);
+ }
+
+ /**
+ * Delete multiple assets.
+ *
+ * @param array $paths File paths
+ * @param string $bucket 'public' or 'private'
+ * @return array Map of path to deletion success status
+ */
+ public function deleteMany(array $paths, string $bucket = 'public'): array
+ {
+ $results = [];
+ $disk = $bucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ foreach ($paths as $path) {
+ $results[$path] = $disk->delete($path);
+ }
+
+ // Bulk delete from CDN storage (requires StorageManager from Plug module)
+ if ($this->storage !== null) {
+ $this->storage->zone($bucket)->delete()->paths($paths);
+ }
+
+ // Purge from CDN cache if enabled
+ if (config('cdn.pipeline.auto_purge', true)) {
+ foreach ($paths as $path) {
+ $this->urlResolver->purge($path);
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get URLs for a path.
+ *
+ * @param string $path File path
+ * @return array{cdn: string, origin: string}
+ */
+ public function urls(string $path): array
+ {
+ return $this->urlResolver->urls($path);
+ }
+
+ /**
+ * Build storage path from category and filename.
+ *
+ * @param string $category Category key (media, social, etc.)
+ * @param string $filename Filename with extension
+ * @param array $options Options including workspace_id, user_id
+ * @return string Full storage path
+ */
+ protected function buildPath(string $category, string $filename, array $options = []): string
+ {
+ $prefix = $this->urlResolver->pathPrefix($category);
+ $parts = [$prefix];
+
+ // Add workspace scope if provided
+ if (isset($options['workspace_id'])) {
+ $parts[] = 'ws_'.$options['workspace_id'];
+ }
+
+ // Add user scope if provided
+ if (isset($options['user_id'])) {
+ $parts[] = 'u_'.$options['user_id'];
+ }
+
+ // Add date partitioning for media files
+ if (in_array($category, ['media', 'social', 'content'])) {
+ $parts[] = date('Y/m');
+ }
+
+ $parts[] = $filename;
+
+ return implode('/', $parts);
+ }
+
+ /**
+ * Generate a unique filename.
+ *
+ * @param UploadedFile $file The uploaded file
+ * @return string Unique filename with original extension
+ */
+ protected function generateFilename(UploadedFile $file): string
+ {
+ $extension = $file->getClientOriginalExtension();
+ $hash = Str::random(16);
+
+ return "{$hash}.{$extension}";
+ }
+
+ /**
+ * Queue a CDN push job if auto-push is enabled.
+ *
+ * @param string $disk Laravel disk name
+ * @param string $path Path within the disk
+ * @param string $zone Target CDN zone ('public' or 'private')
+ */
+ protected function queueCdnPush(string $disk, string $path, string $zone): void
+ {
+ if (! config('cdn.pipeline.auto_push', true)) {
+ return;
+ }
+
+ if (! config('cdn.bunny.push_enabled', false)) {
+ return;
+ }
+
+ $queue = config('cdn.pipeline.queue');
+
+ if ($queue) {
+ 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);
+ if ($diskInstance->exists($path)) {
+ $contents = $diskInstance->get($path);
+ $this->storage->zone($zone)->upload()->contents($path, $contents);
+ }
+ }
+ }
+
+ /**
+ * Check if a file exists in storage.
+ *
+ * @param string $path File path
+ * @param string $bucket 'public' or 'private'
+ * @return bool True if file exists
+ */
+ public function exists(string $path, string $bucket = 'public'): bool
+ {
+ $disk = $bucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ return $disk->exists($path);
+ }
+
+ /**
+ * Get file size in bytes.
+ *
+ * @param string $path File path
+ * @param string $bucket 'public' or 'private'
+ */
+ public function size(string $path, string $bucket = 'public'): ?int
+ {
+ $disk = $bucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ return $disk->exists($path) ? $disk->size($path) : null;
+ }
+
+ /**
+ * Get file MIME type.
+ *
+ * @param string $path File path
+ * @param string $bucket 'public' or 'private'
+ */
+ public function mimeType(string $path, string $bucket = 'public'): ?string
+ {
+ $disk = $bucket === 'private'
+ ? $this->urlResolver->privateDisk()
+ : $this->urlResolver->publicDisk();
+
+ return $disk->exists($path) ? $disk->mimeType($path) : null;
+ }
+}
diff --git a/src/Core/Cdn/Services/BunnyCdnService.php b/src/Core/Cdn/Services/BunnyCdnService.php
new file mode 100644
index 0000000..15066f4
--- /dev/null
+++ b/src/Core/Cdn/Services/BunnyCdnService.php
@@ -0,0 +1,386 @@
+apiKey = $this->config->get('cdn.bunny.api_key') ?? '';
+ $this->pullZoneId = $this->config->get('cdn.bunny.pull_zone_id') ?? '';
+ }
+
+ /**
+ * Sanitize an error message to remove sensitive data like API keys.
+ *
+ * @param string $message The error message to sanitize
+ * @return string The sanitized message with API keys replaced by [REDACTED]
+ */
+ protected function sanitizeErrorMessage(string $message): string
+ {
+ $sensitiveKeys = array_filter([
+ $this->apiKey,
+ $this->config->get('cdn.bunny.storage.public.api_key'),
+ $this->config->get('cdn.bunny.storage.private.api_key'),
+ ]);
+
+ foreach ($sensitiveKeys as $key) {
+ if ($key !== '' && str_contains($message, $key)) {
+ $message = str_replace($key, '[REDACTED]', $message);
+ }
+ }
+
+ return $message;
+ }
+
+ /**
+ * Check if the service is configured.
+ *
+ * @return bool True if BunnyCDN API key and pull zone ID are configured
+ */
+ public function isConfigured(): bool
+ {
+ return $this->config->isConfigured('cdn.bunny');
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Cache Purging
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Purge a single URL from CDN cache.
+ *
+ * @param string $url The full URL to purge from cache
+ * @return bool True if purge was successful, false otherwise
+ */
+ public function purgeUrl(string $url): bool
+ {
+ return $this->purgeUrls([$url]);
+ }
+
+ /**
+ * Purge multiple URLs from CDN cache.
+ *
+ * @param array $urls Array of full URLs to purge from cache
+ * @return bool True if all purges were successful, false if any failed
+ */
+ public function purgeUrls(array $urls): bool
+ {
+ if (! $this->isConfigured()) {
+ Log::warning('BunnyCDN: Cannot purge - not configured');
+
+ return false;
+ }
+
+ try {
+ foreach ($urls as $url) {
+ $response = Http::withHeaders([
+ 'AccessKey' => $this->apiKey,
+ ])->post("{$this->baseUrl}/purge", [
+ 'url' => $url,
+ ]);
+
+ if (! $response->successful()) {
+ Log::error('BunnyCDN: Purge failed', [
+ 'url' => $url,
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ return false;
+ }
+ }
+
+ return true;
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: Purge exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Purge entire pull zone cache.
+ *
+ * @return bool True if purge was successful, false otherwise
+ */
+ public function purgeAll(): bool
+ {
+ if (! $this->isConfigured()) {
+ return false;
+ }
+
+ try {
+ $response = Http::withHeaders([
+ 'AccessKey' => $this->apiKey,
+ ])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache");
+
+ return $response->successful();
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: PurgeAll exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Purge cache by tag.
+ *
+ * @param string $tag The cache tag to purge (e.g., 'workspace-uuid')
+ * @return bool True if purge was successful, false otherwise
+ */
+ public function purgeByTag(string $tag): bool
+ {
+ if (! $this->isConfigured()) {
+ return false;
+ }
+
+ try {
+ $response = Http::withHeaders([
+ 'AccessKey' => $this->apiKey,
+ ])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache", [
+ 'CacheTag' => $tag,
+ ]);
+
+ return $response->successful();
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: PurgeByTag exception', [
+ 'tag' => $tag,
+ 'error' => $this->sanitizeErrorMessage($e->getMessage()),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Purge all cached content for a workspace.
+ *
+ * @param object $workspace Workspace model instance (requires uuid property)
+ * @return bool True if purge was successful, false otherwise
+ */
+ public function purgeWorkspace(object $workspace): bool
+ {
+ return $this->purgeByTag("workspace-{$workspace->uuid}");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Statistics
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Get CDN statistics for pull zone.
+ *
+ * @param string|null $dateFrom Start date in YYYY-MM-DD format
+ * @param string|null $dateTo End date in YYYY-MM-DD format
+ * @return array|null Statistics array or null on failure
+ */
+ public function getStats(?string $dateFrom = null, ?string $dateTo = null): ?array
+ {
+ if (! $this->isConfigured()) {
+ return null;
+ }
+
+ try {
+ $params = [
+ 'pullZone' => $this->pullZoneId,
+ ];
+
+ if ($dateFrom) {
+ $params['dateFrom'] = $dateFrom;
+ }
+ if ($dateTo) {
+ $params['dateTo'] = $dateTo;
+ }
+
+ $response = Http::withHeaders([
+ 'AccessKey' => $this->apiKey,
+ ])->get("{$this->baseUrl}/statistics", $params);
+
+ if ($response->successful()) {
+ return $response->json();
+ }
+
+ return null;
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: GetStats exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return null;
+ }
+ }
+
+ /**
+ * Get bandwidth usage for pull zone.
+ *
+ * @param string|null $dateFrom Start date in YYYY-MM-DD format
+ * @param string|null $dateTo End date in YYYY-MM-DD format
+ * @return array{total_bandwidth: int, cached_bandwidth: int, origin_bandwidth: int}|null Bandwidth stats or null on failure
+ */
+ public function getBandwidth(?string $dateFrom = null, ?string $dateTo = null): ?array
+ {
+ $stats = $this->getStats($dateFrom, $dateTo);
+
+ if (! $stats) {
+ return null;
+ }
+
+ return [
+ 'total_bandwidth' => $stats['TotalBandwidthUsed'] ?? 0,
+ 'cached_bandwidth' => $stats['CacheHitRate'] ?? 0,
+ 'origin_bandwidth' => $stats['TotalOriginTraffic'] ?? 0,
+ ];
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Storage Zone Operations (via API)
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * List files in a storage zone via API.
+ *
+ * Note: For direct storage operations, use BunnyStorageService instead.
+ *
+ * @param string $storageZoneName Name of the storage zone
+ * @param string $path Path within the storage zone (default: root)
+ * @return array>|null Array of file objects or null on failure
+ */
+ public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array
+ {
+ if (! $this->isConfigured()) {
+ return null;
+ }
+
+ try {
+ $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
+ $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
+
+ $url = "https://{$region}/{$storageZoneName}/{$path}";
+
+ $response = Http::withHeaders([
+ 'AccessKey' => $storageApiKey,
+ ])->get($url);
+
+ if ($response->successful()) {
+ return $response->json();
+ }
+
+ return null;
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: ListStorageFiles exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return null;
+ }
+ }
+
+ /**
+ * Upload a file to storage zone via API.
+ *
+ * Note: For direct storage operations, use BunnyStorageService instead.
+ *
+ * @param string $storageZoneName Name of the storage zone
+ * @param string $path Target path within the storage zone
+ * @param string $contents File contents to upload
+ * @return bool True if upload was successful, false otherwise
+ */
+ public function uploadFile(string $storageZoneName, string $path, string $contents): bool
+ {
+ if (! $this->isConfigured()) {
+ return false;
+ }
+
+ try {
+ $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
+ $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
+
+ $url = "https://{$region}/{$storageZoneName}/{$path}";
+
+ $response = Http::withHeaders([
+ 'AccessKey' => $storageApiKey,
+ 'Content-Type' => 'application/octet-stream',
+ ])->withBody($contents, 'application/octet-stream')->put($url);
+
+ return $response->successful();
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: UploadFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Delete a file from storage zone via API.
+ *
+ * Note: For direct storage operations, use BunnyStorageService instead.
+ *
+ * @param string $storageZoneName Name of the storage zone
+ * @param string $path Path of the file to delete
+ * @return bool True if deletion was successful, false otherwise
+ */
+ public function deleteFile(string $storageZoneName, string $path): bool
+ {
+ if (! $this->isConfigured()) {
+ return false;
+ }
+
+ try {
+ $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
+ $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
+
+ $url = "https://{$region}/{$storageZoneName}/{$path}";
+
+ $response = Http::withHeaders([
+ 'AccessKey' => $storageApiKey,
+ ])->delete($url);
+
+ return $response->successful();
+ } catch (\Exception $e) {
+ Log::error('BunnyCDN: DeleteFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
+
+ return false;
+ }
+ }
+}
diff --git a/src/Core/Cdn/Services/BunnyStorageService.php b/src/Core/Cdn/Services/BunnyStorageService.php
new file mode 100644
index 0000000..0d9c7f0
--- /dev/null
+++ b/src/Core/Cdn/Services/BunnyStorageService.php
@@ -0,0 +1,712 @@
+
+ */
+ protected const MIME_TYPES = [
+ // Images
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ 'svg' => 'image/svg+xml',
+ 'ico' => 'image/x-icon',
+ 'avif' => 'image/avif',
+ 'heic' => 'image/heic',
+ 'heif' => 'image/heif',
+
+ // Documents
+ 'pdf' => 'application/pdf',
+ 'doc' => 'application/msword',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'xls' => 'application/vnd.ms-excel',
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'ppt' => 'application/vnd.ms-powerpoint',
+ 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+
+ // Text/Code
+ 'txt' => 'text/plain',
+ 'html' => 'text/html',
+ 'htm' => 'text/html',
+ 'css' => 'text/css',
+ 'js' => 'application/javascript',
+ 'mjs' => 'application/javascript',
+ 'json' => 'application/json',
+ 'xml' => 'application/xml',
+ 'csv' => 'text/csv',
+ 'md' => 'text/markdown',
+
+ // Audio
+ 'mp3' => 'audio/mpeg',
+ 'wav' => 'audio/wav',
+ 'ogg' => 'audio/ogg',
+ 'flac' => 'audio/flac',
+ 'aac' => 'audio/aac',
+ 'm4a' => 'audio/mp4',
+
+ // Video
+ 'mp4' => 'video/mp4',
+ 'webm' => 'video/webm',
+ 'mkv' => 'video/x-matroska',
+ 'avi' => 'video/x-msvideo',
+ 'mov' => 'video/quicktime',
+ 'm4v' => 'video/mp4',
+
+ // Archives
+ 'zip' => 'application/zip',
+ 'tar' => 'application/x-tar',
+ 'gz' => 'application/gzip',
+ 'rar' => 'application/vnd.rar',
+ '7z' => 'application/x-7z-compressed',
+
+ // Fonts
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'otf' => 'font/otf',
+ 'eot' => 'application/vnd.ms-fontobject',
+
+ // Other
+ 'wasm' => 'application/wasm',
+ 'map' => 'application/json',
+ ];
+
+ public function __construct(
+ protected ConfigService $config,
+ ) {}
+
+ /**
+ * Get the public storage zone client.
+ */
+ public function publicClient(): ?Client
+ {
+ if ($this->publicClient === null && $this->isConfigured('public')) {
+ $this->publicClient = new Client(
+ $this->config->get('cdn.bunny.storage.public.api_key'),
+ $this->config->get('cdn.bunny.storage.public.name'),
+ $this->config->get('cdn.bunny.storage.public.region', Client::STORAGE_ZONE_FS_EU)
+ );
+ }
+
+ return $this->publicClient;
+ }
+
+ /**
+ * Get the private storage zone client.
+ */
+ public function privateClient(): ?Client
+ {
+ if ($this->privateClient === null && $this->isConfigured('private')) {
+ $this->privateClient = new Client(
+ $this->config->get('cdn.bunny.storage.private.api_key'),
+ $this->config->get('cdn.bunny.storage.private.name'),
+ $this->config->get('cdn.bunny.storage.private.region', Client::STORAGE_ZONE_FS_EU)
+ );
+ }
+
+ return $this->privateClient;
+ }
+
+ /**
+ * Check if a storage zone is configured.
+ */
+ public function isConfigured(string $zone = 'public'): bool
+ {
+ return $this->config->isConfigured("cdn.bunny.storage.{$zone}");
+ }
+
+ /**
+ * Check if CDN push is enabled.
+ */
+ public function isPushEnabled(): bool
+ {
+ return (bool) $this->config->get('cdn.bunny.push_enabled', false);
+ }
+
+ /**
+ * List files in a storage zone path.
+ */
+ public function list(string $path, string $zone = 'public'): array
+ {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ return [];
+ }
+
+ try {
+ return $client->listFiles($path);
+ } catch (\Exception $e) {
+ Log::error('BunnyStorage: Failed to list files', [
+ 'path' => $path,
+ 'zone' => $zone,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+
+ /**
+ * Upload a file to storage zone.
+ */
+ public function upload(string $localPath, string $remotePath, string $zone = 'public'): bool
+ {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ Log::warning('BunnyStorage: Client not configured', ['zone' => $zone]);
+
+ return false;
+ }
+
+ if (! file_exists($localPath)) {
+ Log::error('BunnyStorage: Local file not found', ['local' => $localPath]);
+
+ return false;
+ }
+
+ $fileSize = filesize($localPath);
+ $maxSize = $this->getMaxFileSize();
+
+ if ($fileSize === false || $fileSize > $maxSize) {
+ Log::error('BunnyStorage: File size exceeds limit', [
+ 'local' => $localPath,
+ 'size' => $fileSize,
+ 'max_size' => $maxSize,
+ ]);
+
+ return false;
+ }
+
+ $contentType = $this->detectContentType($localPath);
+
+ return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $contentType) {
+ // The Bunny SDK upload method accepts optional headers parameter
+ // Pass content-type for proper CDN handling
+ $client->upload($localPath, $remotePath, ['Content-Type' => $contentType]);
+
+ return true;
+ }, [
+ 'local' => $localPath,
+ 'remote' => $remotePath,
+ 'zone' => $zone,
+ 'content_type' => $contentType,
+ ], 'Upload');
+ }
+
+ /**
+ * Get the maximum allowed file size in bytes.
+ */
+ protected function getMaxFileSize(): int
+ {
+ return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE);
+ }
+
+ /**
+ * Detect the MIME content type for a file.
+ *
+ * First tries to detect from file contents using PHP's built-in function,
+ * then falls back to extension-based detection.
+ *
+ * @param string $path File path (local or remote)
+ * @param string|null $contents File contents for content-based detection
+ * @return string MIME type (defaults to application/octet-stream)
+ */
+ public function detectContentType(string $path, ?string $contents = null): string
+ {
+ // Try content-based detection if contents provided and finfo available
+ if ($contents !== null && function_exists('finfo_open')) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ if ($finfo !== false) {
+ $mimeType = finfo_buffer($finfo, $contents);
+ finfo_close($finfo);
+ if ($mimeType !== false && $mimeType !== 'application/octet-stream') {
+ return $mimeType;
+ }
+ }
+ }
+
+ // Try mime_content_type for local files
+ if (file_exists($path) && function_exists('mime_content_type')) {
+ $mimeType = @mime_content_type($path);
+ if ($mimeType !== false && $mimeType !== 'application/octet-stream') {
+ return $mimeType;
+ }
+ }
+
+ // Fall back to extension-based detection
+ return $this->getContentTypeFromExtension($path);
+ }
+
+ /**
+ * Get content type based on file extension.
+ *
+ * @param string $path File path to extract extension from
+ * @return string MIME type (defaults to application/octet-stream)
+ */
+ public function getContentTypeFromExtension(string $path): string
+ {
+ $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ return self::MIME_TYPES[$extension] ?? 'application/octet-stream';
+ }
+
+ /**
+ * Check if a MIME type is for a binary file.
+ */
+ public function isBinaryContentType(string $mimeType): bool
+ {
+ // Text types are not binary
+ if (str_starts_with($mimeType, 'text/')) {
+ return false;
+ }
+
+ // Some application types are text-based
+ $textApplicationTypes = [
+ 'application/json',
+ 'application/xml',
+ 'application/javascript',
+ 'application/x-javascript',
+ ];
+
+ return ! in_array($mimeType, $textApplicationTypes, true);
+ }
+
+ /**
+ * Execute an operation with exponential backoff retry.
+ */
+ protected function executeWithRetry(callable $operation, array $context, string $operationName): bool
+ {
+ $lastException = null;
+
+ for ($attempt = 1; $attempt <= self::MAX_RETRY_ATTEMPTS; $attempt++) {
+ try {
+ return $operation();
+ } catch (\Exception $e) {
+ $lastException = $e;
+
+ if ($attempt < self::MAX_RETRY_ATTEMPTS) {
+ $delayMs = self::RETRY_BASE_DELAY_MS * (2 ** ($attempt - 1));
+ usleep($delayMs * 1000);
+
+ Log::warning("BunnyStorage: {$operationName} attempt {$attempt} failed, retrying", array_merge($context, [
+ 'attempt' => $attempt,
+ 'next_delay_ms' => $delayMs * 2,
+ ]));
+ }
+ }
+ }
+
+ Log::error("BunnyStorage: {$operationName} failed after ".self::MAX_RETRY_ATTEMPTS.' attempts', array_merge($context, [
+ 'error' => $lastException?->getMessage() ?? 'Unknown error',
+ ]));
+
+ return false;
+ }
+
+ /**
+ * Upload file contents directly.
+ */
+ public function putContents(string $remotePath, string $contents, string $zone = 'public'): bool
+ {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ return false;
+ }
+
+ $contentSize = strlen($contents);
+ $maxSize = $this->getMaxFileSize();
+
+ if ($contentSize > $maxSize) {
+ Log::error('BunnyStorage: Content size exceeds limit', [
+ 'remote' => $remotePath,
+ 'size' => $contentSize,
+ 'max_size' => $maxSize,
+ ]);
+
+ return false;
+ }
+
+ $contentType = $this->detectContentType($remotePath, $contents);
+
+ return $this->executeWithRetry(function () use ($client, $remotePath, $contents, $contentType) {
+ // The Bunny SDK putContents method accepts optional headers parameter
+ // Pass content-type for proper CDN handling
+ $client->putContents($remotePath, $contents, ['Content-Type' => $contentType]);
+
+ return true;
+ }, [
+ 'remote' => $remotePath,
+ 'zone' => $zone,
+ 'content_type' => $contentType,
+ ], 'putContents');
+ }
+
+ /**
+ * Download file contents.
+ */
+ public function getContents(string $remotePath, string $zone = 'public'): ?string
+ {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ return null;
+ }
+
+ try {
+ return $client->getContents($remotePath);
+ } catch (\Exception $e) {
+ Log::error('BunnyStorage: getContents failed', [
+ 'remote' => $remotePath,
+ 'zone' => $zone,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return null;
+ }
+ }
+
+ /**
+ * Delete a file from storage zone.
+ */
+ public function delete(string $remotePath, string $zone = 'public'): bool
+ {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ return false;
+ }
+
+ try {
+ $client->delete($remotePath);
+
+ return true;
+ } catch (\Exception $e) {
+ Log::error('BunnyStorage: Delete failed', [
+ 'remote' => $remotePath,
+ 'zone' => $zone,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Delete multiple files.
+ */
+ public function deleteMultiple(array $paths, string $zone = 'public'): array
+ {
+ $results = [];
+
+ foreach ($paths as $path) {
+ $results[$path] = $this->delete($path, $zone);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Copy a file from a Laravel disk to CDN storage zone.
+ */
+ public function copyFromDisk(string $disk, string $path, string $zone = 'public'): bool
+ {
+ $diskInstance = Storage::disk($disk);
+
+ if (! $diskInstance->exists($path)) {
+ Log::warning('BunnyStorage: Source file not found on disk', [
+ 'disk' => $disk,
+ 'path' => $path,
+ ]);
+
+ return false;
+ }
+
+ $contents = $diskInstance->get($path);
+
+ return $this->putContents($path, $contents, $zone);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // vBucket operations for workspace-isolated CDN paths
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Generate a vBucket ID for a domain/workspace.
+ */
+ public function vBucketId(string $domain): string
+ {
+ return LthnHash::vBucketId($domain);
+ }
+
+ /**
+ * Build a vBucket-scoped path.
+ */
+ public function vBucketPath(string $domain, string $path): string
+ {
+ $vBucketId = $this->vBucketId($domain);
+
+ return $vBucketId.'/'.ltrim($path, '/');
+ }
+
+ /**
+ * Upload content with vBucket scoping.
+ */
+ public function vBucketPutContents(string $domain, string $path, string $contents, string $zone = 'public'): bool
+ {
+ $scopedPath = $this->vBucketPath($domain, $path);
+
+ return $this->putContents($scopedPath, $contents, $zone);
+ }
+
+ /**
+ * Upload file with vBucket scoping.
+ */
+ public function vBucketUpload(string $domain, string $localPath, string $remotePath, string $zone = 'public'): bool
+ {
+ $scopedPath = $this->vBucketPath($domain, $remotePath);
+
+ return $this->upload($localPath, $scopedPath, $zone);
+ }
+
+ /**
+ * Get file contents with vBucket scoping.
+ */
+ public function vBucketGetContents(string $domain, string $path, string $zone = 'public'): ?string
+ {
+ $scopedPath = $this->vBucketPath($domain, $path);
+
+ return $this->getContents($scopedPath, $zone);
+ }
+
+ /**
+ * Delete file with vBucket scoping.
+ */
+ public function vBucketDelete(string $domain, string $path, string $zone = 'public'): bool
+ {
+ $scopedPath = $this->vBucketPath($domain, $path);
+
+ return $this->delete($scopedPath, $zone);
+ }
+
+ /**
+ * List files within a vBucket.
+ */
+ public function vBucketList(string $domain, string $path = '', string $zone = 'public'): array
+ {
+ $scopedPath = $this->vBucketPath($domain, $path);
+
+ return $this->list($scopedPath, $zone);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Health Check (implements HealthCheckable)
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Perform a health check on the CDN storage zones.
+ *
+ * Tests connectivity by listing the root directory of configured storage zones.
+ * Returns a HealthCheckResult with status, latency, and zone information.
+ */
+ public function healthCheck(): HealthCheckResult
+ {
+ $publicConfigured = $this->isConfigured('public');
+ $privateConfigured = $this->isConfigured('private');
+
+ if (! $publicConfigured && ! $privateConfigured) {
+ return HealthCheckResult::unknown('No CDN storage zones configured');
+ }
+
+ $results = [];
+ $startTime = microtime(true);
+ $hasError = false;
+ $isDegraded = false;
+
+ // Check public zone
+ if ($publicConfigured) {
+ $publicResult = $this->checkZoneHealth('public');
+ $results['public'] = $publicResult;
+ if (! $publicResult['success']) {
+ $hasError = true;
+ } elseif ($publicResult['latency_ms'] > 1000) {
+ $isDegraded = true;
+ }
+ }
+
+ // Check private zone
+ if ($privateConfigured) {
+ $privateResult = $this->checkZoneHealth('private');
+ $results['private'] = $privateResult;
+ if (! $privateResult['success']) {
+ $hasError = true;
+ } elseif ($privateResult['latency_ms'] > 1000) {
+ $isDegraded = true;
+ }
+ }
+
+ $totalLatency = (microtime(true) - $startTime) * 1000;
+
+ if ($hasError) {
+ return HealthCheckResult::unhealthy(
+ 'One or more CDN storage zones are unreachable',
+ ['zones' => $results],
+ $totalLatency
+ );
+ }
+
+ if ($isDegraded) {
+ return HealthCheckResult::degraded(
+ 'CDN storage zones responding slowly',
+ ['zones' => $results],
+ $totalLatency
+ );
+ }
+
+ return HealthCheckResult::healthy(
+ 'All configured CDN storage zones operational',
+ ['zones' => $results],
+ $totalLatency
+ );
+ }
+
+ /**
+ * Check health of a specific storage zone.
+ *
+ * @param string $zone 'public' or 'private'
+ * @return array{success: bool, latency_ms: float, error?: string}
+ */
+ protected function checkZoneHealth(string $zone): array
+ {
+ $startTime = microtime(true);
+
+ try {
+ $client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
+
+ if (! $client) {
+ return [
+ 'success' => false,
+ 'latency_ms' => 0,
+ 'error' => 'Client not initialized',
+ ];
+ }
+
+ // List root directory as a simple connectivity check
+ // This is a read-only operation that should be fast
+ $client->listFiles('/');
+
+ $latencyMs = (microtime(true) - $startTime) * 1000;
+
+ return [
+ 'success' => true,
+ 'latency_ms' => round($latencyMs, 2),
+ ];
+ } catch (\Exception $e) {
+ $latencyMs = (microtime(true) - $startTime) * 1000;
+
+ Log::warning('BunnyStorage: Health check failed', [
+ 'zone' => $zone,
+ 'error' => $e->getMessage(),
+ 'latency_ms' => $latencyMs,
+ ]);
+
+ return [
+ 'success' => false,
+ 'latency_ms' => round($latencyMs, 2),
+ 'error' => $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Perform a quick connectivity check.
+ *
+ * Simpler than healthCheck() - just returns true/false.
+ *
+ * @param string $zone 'public', 'private', or 'any' (default)
+ */
+ public function isReachable(string $zone = 'any'): bool
+ {
+ if ($zone === 'any') {
+ // Check if any configured zone is reachable
+ if ($this->isConfigured('public')) {
+ $result = $this->checkZoneHealth('public');
+ if ($result['success']) {
+ return true;
+ }
+ }
+
+ if ($this->isConfigured('private')) {
+ $result = $this->checkZoneHealth('private');
+ if ($result['success']) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (! $this->isConfigured($zone)) {
+ return false;
+ }
+
+ $result = $this->checkZoneHealth($zone);
+
+ return $result['success'];
+ }
+}
diff --git a/src/Core/Cdn/Services/CdnUrlBuilder.php b/src/Core/Cdn/Services/CdnUrlBuilder.php
new file mode 100644
index 0000000..3b6285e
--- /dev/null
+++ b/src/Core/Cdn/Services/CdnUrlBuilder.php
@@ -0,0 +1,348 @@
+build($baseUrl, $path);
+ }
+
+ /**
+ * Build an origin storage URL for a path.
+ *
+ * @param string $path Path relative to storage root
+ * @param string|null $baseUrl Optional base URL override (uses config if null)
+ * @return string Full origin URL
+ */
+ public function origin(string $path, ?string $baseUrl = null): string
+ {
+ $baseUrl = $baseUrl ?? config('cdn.urls.public');
+
+ return $this->build($baseUrl, $path);
+ }
+
+ /**
+ * Build a private storage URL for a path.
+ *
+ * @param string $path Path relative to storage root
+ * @param string|null $baseUrl Optional base URL override (uses config if null)
+ * @return string Full private URL
+ */
+ public function private(string $path, ?string $baseUrl = null): string
+ {
+ $baseUrl = $baseUrl ?? config('cdn.urls.private');
+
+ return $this->build($baseUrl, $path);
+ }
+
+ /**
+ * Build an apex domain URL for a path.
+ *
+ * @param string $path Path relative to web root
+ * @param string|null $baseUrl Optional base URL override (uses config if null)
+ * @return string Full apex URL
+ */
+ public function apex(string $path, ?string $baseUrl = null): string
+ {
+ $baseUrl = $baseUrl ?? config('cdn.urls.apex');
+
+ return $this->build($baseUrl, $path);
+ }
+
+ /**
+ * Build a signed URL for private CDN content with token authentication.
+ *
+ * @param string $path Path relative to storage root
+ * @param int|Carbon|null $expiry Expiry time in seconds, or Carbon instance.
+ * Defaults to config('cdn.signed_url_expiry', 3600)
+ * @param string|null $token Optional token override (uses config if null)
+ * @return string|null Signed URL or null if token not configured
+ */
+ public function signed(string $path, int|Carbon|null $expiry = null, ?string $token = null): ?string
+ {
+ $token = $token ?? config('cdn.bunny.private.token');
+
+ if (empty($token)) {
+ return null;
+ }
+
+ // Resolve expiry to Unix timestamp
+ $expires = $this->resolveExpiry($expiry);
+ $path = '/'.ltrim($path, '/');
+
+ // BunnyCDN token authentication format (using HMAC for security)
+ $hashableBase = $token.$path.$expires;
+ $hash = base64_encode(hash_hmac('sha256', $hashableBase, $token, true));
+
+ // URL-safe base64
+ $hash = str_replace(['+', '/'], ['-', '_'], $hash);
+ $hash = rtrim($hash, '=');
+
+ // Build base URL from config
+ $baseUrl = $this->buildSignedUrlBase();
+
+ return "{$baseUrl}{$path}?token={$hash}&expires={$expires}";
+ }
+
+ /**
+ * Build a vBucket-scoped CDN URL.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to vBucket root
+ * @param string|null $baseUrl Optional base URL override
+ * @return string Full vBucket-scoped CDN URL
+ */
+ public function vBucket(string $domain, string $path, ?string $baseUrl = null): string
+ {
+ $vBucketId = $this->vBucketId($domain);
+ $scopedPath = $this->vBucketPath($domain, $path);
+
+ return $this->cdn($scopedPath, $baseUrl);
+ }
+
+ /**
+ * Generate a vBucket ID for a domain/workspace.
+ *
+ * Uses LTHN QuasiHash for deterministic, scoped identifiers.
+ *
+ * @param string $domain The domain name (e.g., "example.com")
+ * @return string 16-character vBucket identifier
+ */
+ public function vBucketId(string $domain): string
+ {
+ return LthnHash::vBucketId($domain);
+ }
+
+ /**
+ * Build a vBucket-scoped storage path.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to vBucket root
+ * @return string Full storage path with vBucket prefix
+ */
+ public function vBucketPath(string $domain, string $path): string
+ {
+ $vBucketId = $this->vBucketId($domain);
+
+ return "{$vBucketId}/".ltrim($path, '/');
+ }
+
+ /**
+ * Build a context-aware asset URL.
+ *
+ * @param string $path Path relative to storage root
+ * @param string $context Context ('admin', 'public')
+ * @return string URL appropriate for the context
+ */
+ public function asset(string $path, string $context = 'public'): string
+ {
+ return $context === 'admin' ? $this->origin($path) : $this->cdn($path);
+ }
+
+ /**
+ * Build a URL with version query parameter for cache busting.
+ *
+ * @param string $url The base URL
+ * @param string|null $version Version hash for cache busting
+ * @return string URL with version parameter
+ */
+ public function withVersion(string $url, ?string $version): string
+ {
+ if (empty($version)) {
+ return $url;
+ }
+
+ $separator = str_contains($url, '?') ? '&' : '?';
+
+ return "{$url}{$separator}id={$version}";
+ }
+
+ /**
+ * Build both CDN and origin URLs for API responses.
+ *
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string}
+ */
+ public function urls(string $path): array
+ {
+ return [
+ 'cdn' => $this->cdn($path),
+ 'origin' => $this->origin($path),
+ ];
+ }
+
+ /**
+ * Build all URL types for a path.
+ *
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string, private: string, apex: string}
+ */
+ public function allUrls(string $path): array
+ {
+ return [
+ 'cdn' => $this->cdn($path),
+ 'origin' => $this->origin($path),
+ 'private' => $this->private($path),
+ 'apex' => $this->apex($path),
+ ];
+ }
+
+ /**
+ * Build vBucket-scoped URLs for API responses.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string, vbucket: string}
+ */
+ public function vBucketUrls(string $domain, string $path): array
+ {
+ $vBucketId = $this->vBucketId($domain);
+ $scopedPath = "{$vBucketId}/{$path}";
+
+ return [
+ 'cdn' => $this->cdn($scopedPath),
+ 'origin' => $this->origin($scopedPath),
+ 'vbucket' => $vBucketId,
+ ];
+ }
+
+ /**
+ * Build a URL from base URL and path.
+ *
+ * @param string|null $baseUrl Base URL (falls back to apex if null)
+ * @param string $path Path to append
+ * @return string Full URL
+ */
+ public function build(?string $baseUrl, string $path): string
+ {
+ if (empty($baseUrl)) {
+ // Fallback to apex domain if no base URL configured
+ $baseUrl = config('cdn.urls.apex', config('app.url'));
+ }
+
+ $baseUrl = rtrim($baseUrl, '/');
+ $path = ltrim($path, '/');
+
+ return "{$baseUrl}/{$path}";
+ }
+
+ /**
+ * Build the base URL for signed private URLs.
+ *
+ * @return string Base URL for signed URLs
+ */
+ protected function buildSignedUrlBase(): string
+ {
+ $pullZone = config('cdn.bunny.private.pull_zone');
+
+ // Support both full URL and just hostname in config
+ if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) {
+ return rtrim($pullZone, '/');
+ }
+
+ return "https://{$pullZone}";
+ }
+
+ /**
+ * Resolve expiry parameter to a Unix timestamp.
+ *
+ * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default
+ * @return int Unix timestamp when the URL expires
+ */
+ protected function resolveExpiry(int|Carbon|null $expiry): int
+ {
+ if ($expiry instanceof Carbon) {
+ return $expiry->timestamp;
+ }
+
+ $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600);
+
+ return time() + $expirySeconds;
+ }
+
+ /**
+ * Get the path prefix for a content category.
+ *
+ * @param string $category Category key from config (media, social, page, etc.)
+ * @return string Path prefix
+ */
+ public function pathPrefix(string $category): string
+ {
+ return config("cdn.paths.{$category}", $category);
+ }
+
+ /**
+ * Build a full path with category prefix.
+ *
+ * @param string $category Category key
+ * @param string $path Relative path within category
+ * @return string Full path with category prefix
+ */
+ public function categoryPath(string $category, string $path): string
+ {
+ $prefix = $this->pathPrefix($category);
+
+ return "{$prefix}/{$path}";
+ }
+}
diff --git a/src/Core/Cdn/Services/FluxCdnService.php b/src/Core/Cdn/Services/FluxCdnService.php
new file mode 100644
index 0000000..7f80550
--- /dev/null
+++ b/src/Core/Cdn/Services/FluxCdnService.php
@@ -0,0 +1,205 @@
+` | Get source-to-CDN path mapping |
+ *
+ * @see CdnUrlBuilder For the underlying URL building logic
+ */
+class FluxCdnService
+{
+ protected CdnUrlBuilder $urlBuilder;
+
+ public function __construct(?CdnUrlBuilder $urlBuilder = null)
+ {
+ $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
+ }
+
+ /**
+ * Get the Flux scripts tag with CDN awareness.
+ *
+ * @param array $options Options like ['nonce' => 'abc123']
+ * @return string HTML script tag
+ */
+ public function scripts(array $options = []): string
+ {
+ $nonce = isset($options['nonce']) ? ' nonce="'.$options['nonce'].'"' : '';
+
+ // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
+ if (! $this->shouldUseCdn()) {
+ return app('flux')->scripts($options);
+ }
+
+ // In production, use CDN URL (no vBucket - shared platform asset)
+ $versionHash = $this->getVersionHash();
+ $filename = config('app.debug') ? 'flux.js' : 'flux.min.js';
+ $url = $this->cdnUrl("flux/{$filename}", $versionHash);
+
+ return '';
+ }
+
+ /**
+ * Get the Flux editor scripts tag with CDN awareness.
+ *
+ * @return string HTML script tag for Flux editor
+ *
+ * @throws \Exception When Flux Pro is not available
+ */
+ public function editorScripts(): string
+ {
+ if (! Flux::pro()) {
+ throw new \Exception('Flux Pro is required to use the Flux editor.');
+ }
+
+ // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
+ if (! $this->shouldUseCdn()) {
+ return \Flux\AssetManager::editorScripts();
+ }
+
+ // In production, use CDN URL (no vBucket - shared platform asset)
+ $versionHash = $this->getVersionHash('/editor.js');
+ $filename = config('app.debug') ? 'editor.js' : 'editor.min.js';
+ $url = $this->cdnUrl("flux/{$filename}", $versionHash);
+
+ return '';
+ }
+
+ /**
+ * Get the Flux editor styles tag with CDN awareness.
+ *
+ * @return string HTML link tag for Flux editor styles
+ *
+ * @throws \Exception When Flux Pro is not available
+ */
+ public function editorStyles(): string
+ {
+ if (! Flux::pro()) {
+ throw new \Exception('Flux Pro is required to use the Flux editor.');
+ }
+
+ // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
+ if (! $this->shouldUseCdn()) {
+ return \Flux\AssetManager::editorStyles();
+ }
+
+ // In production, use CDN URL (no vBucket - shared platform asset)
+ $versionHash = $this->getVersionHash('/editor.css');
+ $url = $this->cdnUrl('flux/editor.css', $versionHash);
+
+ return ' ';
+ }
+
+ /**
+ * Get version hash from Flux manifest.
+ *
+ * @param string $key Manifest key to look up
+ * @return string 8-character hash for cache busting
+ */
+ protected function getVersionHash(string $key = '/flux.js'): string
+ {
+ $manifestPath = Flux::pro()
+ ? base_path('vendor/admin/flux-pro/dist/manifest.json')
+ : base_path('vendor/admin/flux/dist/manifest.json');
+
+ if (! file_exists($manifestPath)) {
+ return substr(md5(config('app.version', '1.0')), 0, 8);
+ }
+
+ $manifest = json_decode(file_get_contents($manifestPath), true);
+
+ return $manifest[$key] ?? substr(md5(config('app.version', '1.0')), 0, 8);
+ }
+
+ /**
+ * Check if we should use CDN for Flux assets.
+ *
+ * Respects CDN_FORCE_LOCAL for testing.
+ *
+ * @return bool True if CDN should be used, false for local assets
+ */
+ public function shouldUseCdn(): bool
+ {
+ return Cdn::isEnabled();
+ }
+
+ /**
+ * Build CDN URL for shared platform assets (no vBucket scoping).
+ *
+ * Flux assets are shared across all workspaces, so they don't use
+ * workspace-specific vBucket prefixes.
+ *
+ * @param string $path Asset path relative to CDN root
+ * @param string|null $version Optional version hash for cache busting
+ * @return string Full CDN URL with optional version query parameter
+ */
+ protected function cdnUrl(string $path, ?string $version = null): string
+ {
+ $cdnUrl = config('cdn.urls.cdn');
+
+ if (empty($cdnUrl)) {
+ $baseUrl = asset($path);
+
+ return $this->urlBuilder->withVersion($baseUrl, $version);
+ }
+
+ $url = $this->urlBuilder->cdn($path);
+
+ return $this->urlBuilder->withVersion($url, $version);
+ }
+
+ /**
+ * Get the list of Flux files that should be uploaded to CDN.
+ *
+ * @return array Map of source path => CDN path
+ */
+ public function getCdnAssetPaths(): array
+ {
+ $basePath = Flux::pro()
+ ? base_path('vendor/admin/flux-pro/dist')
+ : base_path('vendor/admin/flux/dist');
+
+ $files = [
+ "{$basePath}/flux.js" => 'flux/flux.js',
+ "{$basePath}/flux.min.js" => 'flux/flux.min.js',
+ ];
+
+ // Add editor files for Pro
+ if (Flux::pro()) {
+ $files["{$basePath}/editor.js"] = 'flux/editor.js';
+ $files["{$basePath}/editor.min.js"] = 'flux/editor.min.js';
+ $files["{$basePath}/editor.css"] = 'flux/editor.css';
+ }
+
+ return $files;
+ }
+}
diff --git a/src/Core/Cdn/Services/StorageOffload.php b/src/Core/Cdn/Services/StorageOffload.php
new file mode 100644
index 0000000..5914fbe
--- /dev/null
+++ b/src/Core/Cdn/Services/StorageOffload.php
@@ -0,0 +1,409 @@
+disk = config('offload.disk') ?? 'hetzner-public';
+ $this->enabled = config('offload.enabled') ?? false;
+ $this->keepLocal = config('offload.keep_local') ?? true;
+ $this->cdnUrl = config('offload.cdn_url');
+ $this->maxFileSize = config('offload.max_file_size');
+ $this->allowedExtensions = config('offload.allowed_extensions');
+ $this->cacheEnabled = config('offload.cache.enabled') ?? false;
+ }
+
+ /**
+ * Check if storage offload is enabled.
+ */
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ /**
+ * Get the configured disk name.
+ */
+ public function getDiskName(): string
+ {
+ return $this->disk;
+ }
+
+ /**
+ * Get the disk instance.
+ */
+ public function getDisk(): Filesystem
+ {
+ return Storage::disk($this->disk);
+ }
+
+ /**
+ * Check if a local file has been offloaded.
+ */
+ public function isOffloaded(string $localPath): bool
+ {
+ return OffloadModel::where('local_path', $localPath)->exists();
+ }
+
+ /**
+ * Get the offload record for a file.
+ */
+ public function getRecord(string $localPath): ?OffloadModel
+ {
+ return OffloadModel::where('local_path', $localPath)->first();
+ }
+
+ /**
+ * Get the remote URL for an offloaded file.
+ */
+ public function url(string $localPath): ?string
+ {
+ // Check cache first
+ if ($this->cacheEnabled) {
+ $cached = Cache::get("offload_url:{$localPath}");
+ if ($cached !== null) {
+ return $cached ?: null; // Empty string means no record
+ }
+ }
+
+ $record = OffloadModel::where('local_path', $localPath)->first();
+
+ if (! $record) {
+ if ($this->cacheEnabled) {
+ Cache::put("offload_url:{$localPath}", '', 3600);
+ }
+
+ return null;
+ }
+
+ // Use CDN URL if configured, otherwise fall back to disk URL
+ if ($this->cdnUrl) {
+ $url = rtrim($this->cdnUrl, '/').'/'.ltrim($record->remote_path, '/');
+ } else {
+ $url = Storage::disk($this->disk)->url($record->remote_path);
+ }
+
+ if ($this->cacheEnabled) {
+ Cache::put("offload_url:{$localPath}", $url, 3600);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Upload a local file to remote storage.
+ *
+ * @param string $localPath Absolute path to local file
+ * @param string|null $remotePath Custom remote path (auto-generated if null)
+ * @param string $category Category for path prefixing
+ * @param array $metadata Additional metadata to store
+ * @return OffloadModel|null The offload record on success
+ */
+ public function upload(string $localPath, ?string $remotePath = null, string $category = 'media', array $metadata = []): ?OffloadModel
+ {
+ if (! $this->enabled) {
+ Log::debug('StorageOffload: Offload disabled');
+
+ return null;
+ }
+
+ if (! File::exists($localPath)) {
+ Log::warning('StorageOffload: Local file not found', ['path' => $localPath]);
+
+ return null;
+ }
+
+ $fileSize = File::size($localPath);
+
+ // Check max file size
+ if ($this->maxFileSize !== null && $fileSize > $this->maxFileSize) {
+ Log::debug('StorageOffload: File exceeds max size', [
+ 'path' => $localPath,
+ 'size' => $fileSize,
+ 'max' => $this->maxFileSize,
+ ]);
+
+ return null;
+ }
+
+ // Check allowed extensions
+ $extension = strtolower(pathinfo($localPath, PATHINFO_EXTENSION));
+ if ($this->allowedExtensions !== null && ! in_array($extension, $this->allowedExtensions)) {
+ Log::debug('StorageOffload: Extension not allowed', [
+ 'path' => $localPath,
+ 'extension' => $extension,
+ 'allowed' => $this->allowedExtensions,
+ ]);
+
+ return null;
+ }
+
+ // Check if already offloaded
+ if ($this->isOffloaded($localPath)) {
+ Log::debug('StorageOffload: File already offloaded', ['path' => $localPath]);
+
+ return OffloadModel::where('local_path', $localPath)->first();
+ }
+
+ // Generate remote path if not provided
+ $remotePath = $remotePath ?? $this->generateRemotePath($localPath, $category);
+
+ try {
+ // Read file contents
+ $contents = File::get($localPath);
+ $hash = hash('sha256', $contents);
+ $mimeType = File::mimeType($localPath);
+
+ // Upload to remote storage
+ $disk = Storage::disk($this->disk);
+ $uploaded = $disk->put($remotePath, $contents);
+
+ if (! $uploaded) {
+ Log::error('StorageOffload: Upload failed', [
+ 'local' => $localPath,
+ 'remote' => $remotePath,
+ ]);
+
+ return null;
+ }
+
+ // Merge original_name into metadata only if not already set
+ if (! isset($metadata['original_name'])) {
+ $metadata['original_name'] = basename($localPath);
+ }
+
+ // Create tracking record
+ $record = OffloadModel::create([
+ 'local_path' => $localPath,
+ 'remote_path' => $remotePath,
+ 'disk' => $this->disk,
+ 'hash' => $hash,
+ 'file_size' => $fileSize,
+ 'mime_type' => $mimeType,
+ 'category' => $category,
+ 'metadata' => $metadata,
+ 'offloaded_at' => now(),
+ ]);
+
+ // Delete local file if not keeping
+ if (! $this->keepLocal) {
+ File::delete($localPath);
+ }
+
+ Log::info('StorageOffload: File offloaded successfully', [
+ 'local' => $localPath,
+ 'remote' => $remotePath,
+ ]);
+
+ return $record;
+ } catch (\Exception $e) {
+ Log::error('StorageOffload: Exception during upload', [
+ 'path' => $localPath,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return null;
+ }
+ }
+
+ /**
+ * Batch upload multiple files.
+ *
+ * @param array $localPaths List of local paths
+ * @param string $category Category for path prefixing
+ * @return array{uploaded: int, failed: int, skipped: int}
+ */
+ public function uploadBatch(array $localPaths, string $category = 'media'): array
+ {
+ $results = [
+ 'uploaded' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ ];
+
+ foreach ($localPaths as $path) {
+ if ($this->isOffloaded($path)) {
+ $results['skipped']++;
+
+ continue;
+ }
+
+ $record = $this->upload($path, null, $category);
+
+ if ($record) {
+ $results['uploaded']++;
+ } else {
+ $results['failed']++;
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Generate a remote path for a local file.
+ */
+ protected function generateRemotePath(string $localPath, string $category): string
+ {
+ $extension = pathinfo($localPath, PATHINFO_EXTENSION);
+ $hash = Str::random(16);
+
+ // Date-based partitioning
+ $datePath = date('Y/m');
+
+ // Add 's' suffix to category for plural paths
+ $categoryPath = $category;
+ if (! str_ends_with($categoryPath, 's')) {
+ $categoryPath .= 's';
+ }
+
+ return "{$categoryPath}/{$datePath}/{$hash}.{$extension}";
+ }
+
+ /**
+ * Get all offloaded files for a category.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public function getByCategory(string $category)
+ {
+ return OffloadModel::where('category', $category)->get();
+ }
+
+ /**
+ * Delete an offloaded file from remote storage.
+ */
+ public function delete(string $localPath): bool
+ {
+ $record = OffloadModel::where('local_path', $localPath)->first();
+
+ if (! $record) {
+ return false;
+ }
+
+ try {
+ // Delete from remote storage
+ Storage::disk($this->disk)->delete($record->remote_path);
+
+ // Delete tracking record
+ $record->delete();
+
+ // Clear cache
+ if ($this->cacheEnabled) {
+ Cache::forget("offload_url:{$localPath}");
+ }
+
+ return true;
+ } catch (\Exception $e) {
+ Log::error('StorageOffload: Delete failed', [
+ 'path' => $localPath,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Verify file integrity by comparing hash.
+ */
+ public function verifyIntegrity(string $localPath): bool
+ {
+ $record = OffloadModel::where('local_path', $localPath)->first();
+
+ if (! $record) {
+ return false;
+ }
+
+ try {
+ $remoteContents = Storage::disk($this->disk)->get($record->remote_path);
+ $remoteHash = hash('sha256', $remoteContents);
+
+ return hash_equals($record->hash, $remoteHash);
+ } catch (\Exception $e) {
+ Log::error('StorageOffload: Integrity check failed', [
+ 'path' => $localPath,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Get storage statistics.
+ */
+ public function getStats(): array
+ {
+ $totalFiles = OffloadModel::count();
+ $totalSize = OffloadModel::sum('file_size');
+
+ $byCategory = OffloadModel::selectRaw('category, COUNT(*) as count, SUM(file_size) as total_size')
+ ->groupBy('category')
+ ->get()
+ ->keyBy('category')
+ ->toArray();
+
+ return [
+ 'total_files' => $totalFiles,
+ 'total_size' => $totalSize,
+ 'total_size_human' => $this->formatBytes($totalSize),
+ 'by_category' => $byCategory,
+ ];
+ }
+
+ /**
+ * Format bytes to human-readable string.
+ */
+ protected function formatBytes(int|string|null $bytes): string
+ {
+ $bytes = (int) ($bytes ?? 0);
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
+ $power = min($power, count($units) - 1);
+
+ return round($bytes / (1024 ** $power), 2).' '.$units[$power];
+ }
+}
diff --git a/src/Core/Cdn/Services/StorageUrlResolver.php b/src/Core/Cdn/Services/StorageUrlResolver.php
new file mode 100644
index 0000000..0c5aff4
--- /dev/null
+++ b/src/Core/Cdn/Services/StorageUrlResolver.php
@@ -0,0 +1,498 @@
+ Origin URLs (Hetzner)
+ * - Public/embed requests -> CDN URLs (BunnyCDN)
+ * - API requests -> Both URLs returned
+ *
+ * Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash.
+ *
+ * URL building is delegated to CdnUrlBuilder for consistency across services.
+ *
+ * ## Methods
+ *
+ * | Method | Returns | Description |
+ * |--------|---------|-------------|
+ * | `vBucketId()` | `string` | Generate vBucket ID for a domain |
+ * | `vBucketCdn()` | `string` | Get CDN URL with vBucket scoping |
+ * | `vBucketOrigin()` | `string` | Get origin URL with vBucket scoping |
+ * | `vBucketPath()` | `string` | Build vBucket-scoped storage path |
+ * | `vBucketUrls()` | `array` | Get both URLs with vBucket scoping |
+ * | `cdn()` | `string` | Get CDN delivery URL for a path |
+ * | `origin()` | `string` | Get origin URL (Hetzner) for a path |
+ * | `private()` | `string` | Get private storage URL for a path |
+ * | `signedUrl()` | `string\|null` | Get signed URL for private content |
+ * | `apex()` | `string` | Get apex domain URL for a path |
+ * | `asset()` | `string` | Get context-aware URL for a path |
+ * | `urls()` | `array` | Get both CDN and origin URLs |
+ * | `allUrls()` | `array` | Get all URLs (cdn, origin, private, apex) |
+ * | `detectContext()` | `string` | Detect current request context |
+ * | `isAdminContext()` | `bool` | Check if current context is admin |
+ * | `pushToCdn()` | `bool` | Push a file to CDN storage zone |
+ * | `deleteFromCdn()` | `bool` | Delete a file from CDN storage zone |
+ * | `purge()` | `bool` | Purge a path from CDN cache |
+ * | `cachedAsset()` | `string` | Get cached CDN URL with intelligent caching |
+ * | `publicDisk()` | `Filesystem` | Get the public storage disk |
+ * | `privateDisk()` | `Filesystem` | Get the private storage disk |
+ * | `storePublic()` | `bool` | Store file to public bucket |
+ * | `storePrivate()` | `bool` | Store file to private bucket |
+ * | `deleteAsset()` | `bool` | Delete file from storage and CDN |
+ * | `pathPrefix()` | `string` | Get path prefix for a category |
+ * | `categoryPath()` | `string` | Build full path with category prefix |
+ *
+ * @see CdnUrlBuilder For the underlying URL building logic
+ */
+class StorageUrlResolver
+{
+ protected BunnyStorageService $bunnyStorage;
+
+ protected CdnUrlBuilder $urlBuilder;
+
+ public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null)
+ {
+ $this->bunnyStorage = $bunnyStorage;
+ $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
+ }
+
+ /**
+ * Get the URL builder instance.
+ */
+ public function getUrlBuilder(): CdnUrlBuilder
+ {
+ return $this->urlBuilder;
+ }
+
+ /**
+ * Generate a vBucket ID for a domain/workspace.
+ *
+ * Uses LTHN QuasiHash protocol for deterministic, scoped identifiers.
+ * Format: cdn.host.uk.com/{vBucketId}/path/to/asset.js
+ *
+ * @param string $domain The domain name (e.g., "host.uk.com")
+ * @return string 16-character vBucket identifier
+ */
+ public function vBucketId(string $domain): string
+ {
+ return $this->urlBuilder->vBucketId($domain);
+ }
+
+ /**
+ * Get CDN URL with vBucket scoping for workspace isolation.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to storage root
+ */
+ public function vBucketCdn(string $domain, string $path): string
+ {
+ return $this->urlBuilder->vBucket($domain, $path);
+ }
+
+ /**
+ * Get origin URL with vBucket scoping for workspace isolation.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to storage root
+ */
+ public function vBucketOrigin(string $domain, string $path): string
+ {
+ $scopedPath = $this->urlBuilder->vBucketPath($domain, $path);
+
+ return $this->urlBuilder->origin($scopedPath);
+ }
+
+ /**
+ * Build a vBucket-scoped storage path.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to vBucket root
+ */
+ public function vBucketPath(string $domain, string $path): string
+ {
+ return $this->urlBuilder->vBucketPath($domain, $path);
+ }
+
+ /**
+ * Get both URLs with vBucket scoping for API responses.
+ *
+ * @param string $domain The workspace domain for scoping
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string, vbucket: string}
+ */
+ public function vBucketUrls(string $domain, string $path): array
+ {
+ return $this->urlBuilder->vBucketUrls($domain, $path);
+ }
+
+ /**
+ * Get the CDN delivery URL for a path.
+ * Always returns the BunnyCDN pull zone URL.
+ *
+ * @param string $path Path relative to storage root
+ */
+ public function cdn(string $path): string
+ {
+ return $this->urlBuilder->cdn($path);
+ }
+
+ /**
+ * Get the origin URL for a path (Hetzner public bucket).
+ * Direct access to origin storage, bypassing CDN.
+ *
+ * @param string $path Path relative to storage root
+ */
+ public function origin(string $path): string
+ {
+ return $this->urlBuilder->origin($path);
+ }
+
+ /**
+ * Get the private storage URL for a path.
+ * For DRM/gated content - not publicly accessible.
+ *
+ * @param string $path Path relative to storage root
+ */
+ public function private(string $path): string
+ {
+ return $this->urlBuilder->private($path);
+ }
+
+ /**
+ * Get a signed URL for private CDN content with token authentication.
+ * Generates time-limited access URLs for gated/DRM content.
+ *
+ * @param string $path Path relative to storage root
+ * @param int|Carbon|null $expiry Expiry time in seconds, or a Carbon instance for absolute expiry.
+ * Defaults to config('cdn.signed_url_expiry', 3600) when null.
+ * @return string|null Signed URL or null if token not configured
+ */
+ public function signedUrl(string $path, int|Carbon|null $expiry = null): ?string
+ {
+ return $this->urlBuilder->signed($path, $expiry);
+ }
+
+ /**
+ * Build the base URL for signed private URLs.
+ * Uses config for the private pull zone URL.
+ *
+ * @deprecated Use CdnUrlBuilder::signed() instead
+ */
+ protected function buildSignedUrlBase(): string
+ {
+ $pullZone = config('cdn.bunny.private.pull_zone');
+
+ // Support both full URL and just hostname in config
+ if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) {
+ return rtrim($pullZone, '/');
+ }
+
+ return "https://{$pullZone}";
+ }
+
+ /**
+ * Get the apex domain URL for a path.
+ * Fallback for assets served through main domain.
+ *
+ * @param string $path Path relative to web root
+ */
+ public function apex(string $path): string
+ {
+ return $this->urlBuilder->apex($path);
+ }
+
+ /**
+ * Get context-aware URL for a path.
+ * Automatically determines whether to return CDN or origin URL.
+ *
+ * @param string $path Path relative to storage root
+ * @param string|null $context Force context ('admin', 'public', or null for auto)
+ */
+ public function asset(string $path, ?string $context = null): string
+ {
+ $context = $context ?? $this->detectContext();
+
+ return $this->urlBuilder->asset($path, $context);
+ }
+
+ /**
+ * Get both CDN and origin URLs for API responses.
+ *
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string}
+ */
+ public function urls(string $path): array
+ {
+ return $this->urlBuilder->urls($path);
+ }
+
+ /**
+ * Get all URLs for a path (including private and apex).
+ *
+ * @param string $path Path relative to storage root
+ * @return array{cdn: string, origin: string, private: string, apex: string}
+ */
+ public function allUrls(string $path): array
+ {
+ return $this->urlBuilder->allUrls($path);
+ }
+
+ /**
+ * Detect the current request context based on headers and route.
+ *
+ * Checks for admin headers and route prefixes to determine context.
+ *
+ * @return string 'admin' or 'public'
+ */
+ public function detectContext(): string
+ {
+ // Check for admin headers
+ foreach (config('cdn.context.admin_headers', []) as $header) {
+ if (Request::hasHeader($header)) {
+ return 'admin';
+ }
+ }
+
+ // Check for admin route prefixes
+ $path = Request::path();
+ foreach (config('cdn.context.admin_prefixes', []) as $prefix) {
+ if (str_starts_with($path, $prefix)) {
+ return 'admin';
+ }
+ }
+
+ return config('cdn.context.default', 'public');
+ }
+
+ /**
+ * Check if the current context is admin/internal.
+ *
+ * @return bool True if in admin context
+ */
+ public function isAdminContext(): bool
+ {
+ return $this->detectContext() === 'admin';
+ }
+
+ /**
+ * Push a file to the CDN storage zone.
+ *
+ * @param string $disk Laravel disk name ('hetzner-public' or 'hetzner-private')
+ * @param string $path Path within the disk
+ * @param string $zone Target zone ('public' or 'private')
+ */
+ public function pushToCdn(string $disk, string $path, string $zone = 'public'): bool
+ {
+ if (! $this->bunnyStorage->isPushEnabled()) {
+ return false;
+ }
+
+ return $this->bunnyStorage->copyFromDisk($disk, $path, $zone);
+ }
+
+ /**
+ * Delete a file from the CDN storage zone.
+ *
+ * @param string $path Path within the storage zone
+ * @param string $zone Target zone ('public' or 'private')
+ */
+ public function deleteFromCdn(string $path, string $zone = 'public'): bool
+ {
+ return $this->bunnyStorage->delete($path, $zone);
+ }
+
+ /**
+ * Purge a path from the CDN cache.
+ * Uses the existing BunnyCdnService for pull zone API.
+ *
+ * @param string $path Path to purge
+ */
+ public function purge(string $path): bool
+ {
+ // Use existing BunnyCdnService for pull zone operations
+ $bunnyCdnService = app(BunnyCdnService::class);
+
+ if (! $bunnyCdnService->isConfigured()) {
+ return false;
+ }
+
+ return $bunnyCdnService->purgeUrl($this->cdn($path));
+ }
+
+ /**
+ * Get cached CDN URL with intelligent caching.
+ *
+ * @param string $path Path relative to storage root
+ * @param string|null $context Force context ('admin', 'public', or null for auto)
+ */
+ public function cachedAsset(string $path, ?string $context = null): string
+ {
+ if (! config('cdn.cache.enabled', true)) {
+ return $this->asset($path, $context);
+ }
+
+ $context = $context ?? $this->detectContext();
+ $cacheKey = config('cdn.cache.prefix', 'cdn_url').':'.$context.':'.md5($path);
+ $ttl = config('cdn.cache.ttl', 3600);
+
+ return Cache::remember($cacheKey, $ttl, fn () => $this->asset($path, $context));
+ }
+
+ /**
+ * Build a URL from base URL and path.
+ *
+ * @param string|null $baseUrl Base URL (falls back to apex if null)
+ * @param string $path Path to append
+ * @return string Full URL
+ *
+ * @deprecated Use CdnUrlBuilder::build() instead
+ */
+ protected function buildUrl(?string $baseUrl, string $path): string
+ {
+ return $this->urlBuilder->build($baseUrl, $path);
+ }
+
+ /**
+ * Get the public storage disk.
+ *
+ * @return \Illuminate\Contracts\Filesystem\Filesystem
+ */
+ public function publicDisk()
+ {
+ return Storage::disk(config('cdn.disks.public', 'hetzner-public'));
+ }
+
+ /**
+ * Get the private storage disk.
+ *
+ * @return \Illuminate\Contracts\Filesystem\Filesystem
+ */
+ public function privateDisk()
+ {
+ return Storage::disk(config('cdn.disks.private', 'hetzner-private'));
+ }
+
+ /**
+ * Store file to public bucket and optionally push to CDN.
+ *
+ * @param string $path Target path
+ * @param string|resource $contents File contents or stream
+ * @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
+ */
+ public function storePublic(string $path, $contents, bool $pushToCdn = true): bool
+ {
+ $stored = $this->publicDisk()->put($path, $contents);
+
+ 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);
+ } else {
+ $this->pushToCdn('hetzner-public', $path, 'public');
+ }
+ }
+
+ return $stored;
+ }
+
+ /**
+ * Store file to private bucket and optionally push to CDN.
+ *
+ * @param string $path Target path
+ * @param string|resource $contents File contents or stream
+ * @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
+ */
+ public function storePrivate(string $path, $contents, bool $pushToCdn = true): bool
+ {
+ $stored = $this->privateDisk()->put($path, $contents);
+
+ 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);
+ } else {
+ $this->pushToCdn('hetzner-private', $path, 'private');
+ }
+ }
+
+ return $stored;
+ }
+
+ /**
+ * Delete file from storage and CDN.
+ *
+ * @param string $path File path
+ * @param string $bucket 'public' or 'private'
+ */
+ public function deleteAsset(string $path, string $bucket = 'public'): bool
+ {
+ $disk = $bucket === 'private' ? $this->privateDisk() : $this->publicDisk();
+ $deleted = $disk->delete($path);
+
+ if ($deleted) {
+ $this->deleteFromCdn($path, $bucket);
+
+ if (config('cdn.pipeline.auto_purge', true)) {
+ $this->purge($path);
+ }
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * Get the path prefix for a content category.
+ *
+ * @param string $category Category key from config (media, social, page, etc.)
+ */
+ public function pathPrefix(string $category): string
+ {
+ return $this->urlBuilder->pathPrefix($category);
+ }
+
+ /**
+ * Build a full path with category prefix.
+ *
+ * @param string $category Category key
+ * @param string $path Relative path within category
+ */
+ public function categoryPath(string $category, string $path): string
+ {
+ return $this->urlBuilder->categoryPath($category, $path);
+ }
+
+ /**
+ * Resolve expiry parameter to a Unix timestamp.
+ *
+ * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default
+ * @return int Unix timestamp when the URL expires
+ *
+ * @deprecated Use CdnUrlBuilder internally instead
+ */
+ protected function resolveExpiry(int|Carbon|null $expiry): int
+ {
+ if ($expiry instanceof Carbon) {
+ return $expiry->timestamp;
+ }
+
+ $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600);
+
+ return time() + $expirySeconds;
+ }
+}
diff --git a/src/Core/Cdn/Traits/HasCdnUrls.php b/src/Core/Cdn/Traits/HasCdnUrls.php
new file mode 100644
index 0000000..35728cc
--- /dev/null
+++ b/src/Core/Cdn/Traits/HasCdnUrls.php
@@ -0,0 +1,186 @@
+getCdnPath();
+
+ return $path ? Cdn::cdn($path) : null;
+ }
+
+ /**
+ * Get the origin storage URL (Hetzner) for this model's asset.
+ */
+ public function getOriginUrl(): ?string
+ {
+ $path = $this->getCdnPath();
+
+ return $path ? Cdn::origin($path) : null;
+ }
+
+ /**
+ * Get context-aware URL for this model's asset.
+ * Returns CDN URL for public context, origin URL for admin context.
+ *
+ * @param string|null $context Force context ('admin', 'public', or null for auto)
+ */
+ public function getAssetUrl(?string $context = null): ?string
+ {
+ $path = $this->getCdnPath();
+
+ return $path ? Cdn::asset($path, $context) : null;
+ }
+
+ /**
+ * Get both CDN and origin URLs for API responses.
+ *
+ * @return array{cdn: string|null, origin: string|null}
+ */
+ public function getAssetUrls(): array
+ {
+ $path = $this->getCdnPath();
+
+ if (! $path) {
+ return ['cdn' => null, 'origin' => null];
+ }
+
+ return Cdn::urls($path);
+ }
+
+ /**
+ * Get all URLs for this model's asset (including private and apex).
+ *
+ * @return array{cdn: string|null, origin: string|null, private: string|null, apex: string|null}
+ */
+ public function getAllAssetUrls(): array
+ {
+ $path = $this->getCdnPath();
+
+ if (! $path) {
+ return ['cdn' => null, 'origin' => null, 'private' => null, 'apex' => null];
+ }
+
+ return Cdn::allUrls($path);
+ }
+
+ /**
+ * Get the storage path for this model's asset.
+ */
+ public function getCdnPath(): ?string
+ {
+ $attribute = $this->getCdnPathAttribute();
+
+ return $this->{$attribute} ?? null;
+ }
+
+ /**
+ * Get the attribute name containing the storage path.
+ * Override this method or set $cdnPathAttribute property.
+ */
+ protected function getCdnPathAttribute(): string
+ {
+ return $this->cdnPathAttribute ?? 'path';
+ }
+
+ /**
+ * Get the bucket type for this model's assets.
+ * Override this method or set $cdnBucket property.
+ *
+ * @return string 'public' or 'private'
+ */
+ public function getCdnBucket(): string
+ {
+ return $this->cdnBucket ?? 'public';
+ }
+
+ /**
+ * Push this model's asset to the CDN storage zone.
+ */
+ public function pushToCdn(): bool
+ {
+ $path = $this->getCdnPath();
+
+ if (! $path) {
+ return false;
+ }
+
+ $bucket = $this->getCdnBucket();
+ $disk = $bucket === 'private' ? 'hetzner-private' : 'hetzner-public';
+
+ return Cdn::pushToCdn($disk, $path, $bucket);
+ }
+
+ /**
+ * Delete this model's asset from the CDN storage zone.
+ */
+ public function deleteFromCdn(): bool
+ {
+ $path = $this->getCdnPath();
+
+ if (! $path) {
+ return false;
+ }
+
+ return Cdn::deleteFromCdn($path, $this->getCdnBucket());
+ }
+
+ /**
+ * Purge this model's asset from the CDN cache.
+ */
+ public function purgeFromCdn(): bool
+ {
+ $path = $this->getCdnPath();
+
+ if (! $path) {
+ return false;
+ }
+
+ return Cdn::purge($path);
+ }
+
+ /**
+ * Scope to append CDN URLs to the model when converting to array/JSON.
+ * Add this to $appends property: 'cdn_url', 'origin_url', 'asset_urls'
+ */
+ public function getCdnUrlAttribute(): ?string
+ {
+ return $this->getCdnUrl();
+ }
+
+ public function getOriginUrlAttribute(): ?string
+ {
+ return $this->getOriginUrl();
+ }
+
+ public function getAssetUrlsAttribute(): array
+ {
+ return $this->getAssetUrls();
+ }
+}
diff --git a/src/Core/Cdn/config.php b/src/Core/Cdn/config.php
new file mode 100644
index 0000000..6936cde
--- /dev/null
+++ b/src/Core/Cdn/config.php
@@ -0,0 +1,182 @@
+ env('CDN_ENABLED', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Signed URL Expiry
+ |--------------------------------------------------------------------------
+ |
+ | Default expiry time (in seconds) for signed URLs when not specified
+ | per-request. Signed URLs provide time-limited access to private content.
+ |
+ */
+ 'signed_url_expiry' => env('CDN_SIGNED_URL_EXPIRY', 3600),
+
+ /*
+ |--------------------------------------------------------------------------
+ | URL Configuration
+ |--------------------------------------------------------------------------
+ |
+ | All URL building uses these config values for consistency.
+ | Never hardcode URLs in service methods.
+ |
+ */
+ 'urls' => [
+ // CDN delivery URL (when enabled)
+ 'cdn' => env('CDN_URL'),
+
+ // Public origin URL (direct storage access, bypassing CDN)
+ 'public' => env('CDN_PUBLIC_URL'),
+
+ // Private CDN URL (for signed/gated content)
+ 'private' => env('CDN_PRIVATE_URL'),
+
+ // Apex domain fallback
+ 'apex' => env('APP_URL', 'https://core.test'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Filesystem Disk Mapping
+ |--------------------------------------------------------------------------
+ */
+ 'disks' => [
+ 'private' => env('CDN_PRIVATE_DISK', 'local'),
+ 'public' => env('CDN_PUBLIC_DISK', 'public'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | BunnyCDN Configuration (Optional)
+ |--------------------------------------------------------------------------
+ |
+ | Only needed if using BunnyCDN as your CDN provider.
+ |
+ */
+ 'bunny' => [
+ // Public storage zone (compiled assets)
+ 'public' => [
+ 'zone' => env('BUNNYCDN_PUBLIC_STORAGE_ZONE'),
+ 'region' => env('BUNNYCDN_PUBLIC_STORAGE_REGION', 'de'),
+ 'api_key' => env('BUNNYCDN_PUBLIC_STORAGE_API_KEY'),
+ 'read_only_key' => env('BUNNYCDN_PUBLIC_STORAGE_READ_KEY'),
+ 'pull_zone' => env('BUNNYCDN_PUBLIC_PULL_ZONE'),
+ ],
+
+ // Private storage zone (DRM, gated content)
+ 'private' => [
+ 'zone' => env('BUNNYCDN_PRIVATE_STORAGE_ZONE'),
+ 'region' => env('BUNNYCDN_PRIVATE_STORAGE_REGION', 'de'),
+ 'api_key' => env('BUNNYCDN_PRIVATE_STORAGE_API_KEY'),
+ 'read_only_key' => env('BUNNYCDN_PRIVATE_STORAGE_READ_KEY'),
+ 'pull_zone' => env('BUNNYCDN_PRIVATE_PULL_ZONE'),
+ 'token' => env('BUNNYCDN_PRIVATE_PULL_ZONE_TOKEN'),
+ ],
+
+ // Account-level API (for cache purging)
+ 'pull_zone_id' => env('BUNNYCDN_PULL_ZONE_ID'),
+ 'api_key' => env('BUNNYCDN_API_KEY'),
+
+ // Feature flags
+ 'push_enabled' => env('CDN_PUSH_ENABLED', false),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Context Detection
+ |--------------------------------------------------------------------------
+ |
+ | Define which routes/contexts should use origin vs CDN URLs.
+ |
+ */
+ 'context' => [
+ // Route prefixes that should use origin URLs (admin/internal)
+ 'admin_prefixes' => ['admin', 'hub', 'api/v1/admin', 'dashboard'],
+
+ // Headers that indicate internal/admin request
+ 'admin_headers' => ['X-Admin-Request', 'X-Internal-Request'],
+
+ // Default context when not determinable
+ 'default' => 'public',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Asset Processing Pipeline
+ |--------------------------------------------------------------------------
+ */
+ 'pipeline' => [
+ // Auto-push to CDN after processing (only when CDN enabled)
+ 'auto_push' => env('CDN_AUTO_PUSH', false),
+
+ // Auto-purge CDN on asset update
+ 'auto_purge' => env('CDN_AUTO_PURGE', false),
+
+ // Queue for async operations
+ 'queue' => env('CDN_QUEUE', 'default'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Path Prefixes by Category
+ |--------------------------------------------------------------------------
+ */
+ 'paths' => [
+ 'media' => 'media',
+ 'avatar' => 'avatars',
+ 'content' => 'content',
+ 'static' => 'static',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Local Cache Configuration
+ |--------------------------------------------------------------------------
+ |
+ | When CDN is disabled, these settings control local asset caching.
+ |
+ */
+ 'cache' => [
+ 'enabled' => env('CDN_CACHE_ENABLED', true),
+ 'ttl' => env('CDN_CACHE_TTL', 3600),
+ 'prefix' => 'cdn_url',
+
+ // Cache headers for static assets (when serving locally)
+ 'headers' => [
+ 'max_age' => env('CDN_CACHE_MAX_AGE', 31536000), // 1 year
+ 'immutable' => env('CDN_CACHE_IMMUTABLE', true),
+ ],
+ ],
+];
diff --git a/src/Core/Cdn/offload.php b/src/Core/Cdn/offload.php
new file mode 100644
index 0000000..cf37019
--- /dev/null
+++ b/src/Core/Cdn/offload.php
@@ -0,0 +1,115 @@
+ env('STORAGE_OFFLOAD_ENABLED', false),
+
+ /**
+ * Default disk for offloading.
+ * Must be defined in config/filesystems.php
+ *
+ * Options: 'hetzner', 's3', or any custom S3-compatible disk
+ */
+ 'disk' => env('STORAGE_OFFLOAD_DISK', 'hetzner'),
+
+ /**
+ * Base URL for serving offloaded assets.
+ * Can be a CDN URL (e.g., BunnyCDN pull zone).
+ *
+ * If null, uses the disk's configured URL.
+ */
+ 'cdn_url' => env('STORAGE_OFFLOAD_CDN_URL'),
+
+ /**
+ * Hetzner Object Storage Configuration.
+ */
+ 'hetzner' => [
+ 'endpoint' => env('HETZNER_ENDPOINT', 'https://fsn1.your-objectstorage.com'),
+ 'region' => env('HETZNER_REGION', 'fsn1'),
+ 'bucket' => env('HETZNER_BUCKET'),
+ 'access_key' => env('HETZNER_ACCESS_KEY'),
+ 'secret_key' => env('HETZNER_SECRET_KEY'),
+ 'visibility' => 'public',
+ ],
+
+ /**
+ * File path organisation within bucket.
+ */
+ 'paths' => [
+ 'page' => 'pages',
+ 'avatar' => 'avatars',
+ 'media' => 'media',
+ 'static' => 'static',
+ ],
+
+ /**
+ * File types eligible for offloading.
+ */
+ 'allowed_extensions' => [
+ // Images
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico',
+ // Documents
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ // Media
+ 'mp4', 'webm', 'mp3', 'wav', 'ogg',
+ // Archives
+ 'zip', 'tar', 'gz',
+ ],
+
+ /**
+ * Maximum file size for offload (bytes).
+ * Files larger than this will remain local.
+ */
+ 'max_file_size' => env('STORAGE_OFFLOAD_MAX_SIZE', 100 * 1024 * 1024), // 100MB
+
+ /**
+ * Automatically offload on upload.
+ * If false, manual migration via artisan command required.
+ */
+ 'auto_offload' => env('STORAGE_OFFLOAD_AUTO', true),
+
+ /**
+ * Keep local copy after offloading.
+ * Useful for gradual migration or backup purposes.
+ */
+ 'keep_local' => env('STORAGE_OFFLOAD_KEEP_LOCAL', false),
+
+ /**
+ * Queue configuration for async offloading.
+ */
+ 'queue' => [
+ 'enabled' => env('STORAGE_OFFLOAD_QUEUE', true),
+ 'connection' => env('STORAGE_OFFLOAD_QUEUE_CONNECTION', 'redis'),
+ 'name' => env('STORAGE_OFFLOAD_QUEUE_NAME', 'storage-offload'),
+ ],
+
+ /**
+ * Cache configuration for URL lookups.
+ */
+ 'cache' => [
+ 'enabled' => true,
+ 'ttl' => 3600, // 1 hour
+ 'prefix' => 'storage_offload',
+ ],
+
+];
diff --git a/src/Core/Config/Boot.php b/src/Core/Config/Boot.php
new file mode 100644
index 0000000..7b884ba
--- /dev/null
+++ b/src/Core/Config/Boot.php
@@ -0,0 +1,136 @@
+get('cdn.bunny.api_key', $workspace);
+ * if ($config->isConfigured('cdn.bunny', $workspace)) { ... }
+ *
+ * ## Import/Export
+ *
+ * Export config to JSON or YAML for backup, migration, or sharing:
+ *
+ * ```php
+ * $exporter = app(ConfigExporter::class);
+ * $json = $exporter->exportJson($workspace);
+ * $result = $exporter->importJson($json, $workspace);
+ * ```
+ *
+ * CLI commands:
+ * - `config:export config.json` - Export to file
+ * - `config:import config.json` - Import from file
+ *
+ * ## Versioning & Rollback
+ *
+ * Create snapshots and rollback to previous states:
+ *
+ * ```php
+ * $versioning = app(ConfigVersioning::class);
+ * $version = $versioning->createVersion($workspace, 'Before migration');
+ * $versioning->rollback($version->id, $workspace);
+ * ```
+ *
+ * CLI commands:
+ * - `config:version list` - List all versions
+ * - `config:version create "Label"` - Create snapshot
+ * - `config:version rollback 123` - Rollback to version
+ * - `config:version compare 122 123` - Compare versions
+ *
+ * ## Configuration
+ *
+ * | Key | Type | Default | Description |
+ * |-----|------|---------|-------------|
+ * | `core.config.max_versions` | int | 50 | Max versions per scope |
+ */
+class Boot extends ServiceProvider
+{
+ /**
+ * Register services.
+ */
+ public function register(): void
+ {
+ $this->app->singleton(ConfigResolver::class);
+
+ $this->app->singleton(ConfigService::class, function ($app) {
+ return new ConfigService($app->make(ConfigResolver::class));
+ });
+
+ // Alias for convenience
+ $this->app->alias(ConfigService::class, 'config.service');
+
+ // Register exporter service
+ $this->app->singleton(ConfigExporter::class, function ($app) {
+ return new ConfigExporter($app->make(ConfigService::class));
+ });
+
+ // Register versioning service
+ $this->app->singleton(ConfigVersioning::class, function ($app) {
+ return new ConfigVersioning(
+ $app->make(ConfigService::class),
+ $app->make(ConfigExporter::class)
+ );
+ });
+ }
+
+ /**
+ * Bootstrap services.
+ */
+ public function boot(): void
+ {
+ // Load migrations
+ $this->loadMigrationsFrom(__DIR__.'/Migrations');
+
+ // Load views
+ $this->loadViewsFrom(__DIR__.'/View/Blade', 'core.config');
+
+ // Load routes
+ $this->loadRoutesFrom(__DIR__.'/Routes/admin.php');
+
+ // Register Livewire components
+ Livewire::component('app.core.config.view.modal.admin.workspace-config', View\Modal\Admin\WorkspaceConfig::class);
+ Livewire::component('app.core.config.view.modal.admin.config-panel', View\Modal\Admin\ConfigPanel::class);
+
+ // Register console commands
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ Console\ConfigPrimeCommand::class,
+ Console\ConfigListCommand::class,
+ Console\ConfigExportCommand::class,
+ Console\ConfigImportCommand::class,
+ Console\ConfigVersionCommand::class,
+ ]);
+ }
+
+ // Boot key registry after app is ready (deferred to avoid DB during boot)
+ // Config resolver now uses lazy loading - no boot-time initialization needed
+ }
+
+ /**
+ * Check if database is unavailable (migration context).
+ */
+ protected function isDbUnavailable(): bool
+ {
+ // Check if we're running migrate or db commands
+ $command = $_SERVER['argv'][1] ?? '';
+
+ return in_array($command, ['migrate', 'migrate:fresh', 'migrate:reset', 'db:seed', 'db:wipe']);
+ }
+}
diff --git a/src/Core/Config/Config.php b/src/Core/Config/Config.php
new file mode 100644
index 0000000..d06d85e
--- /dev/null
+++ b/src/Core/Config/Config.php
@@ -0,0 +1,171 @@
+ $data Override data (optional)
+ */
+ public function save(array $data = []): void
+ {
+ foreach ($this->form() as $name => $_) {
+ $payload = Arr::get($data, $name, $this->request?->input($name));
+
+ $this->persistData($name, $payload);
+ }
+ }
+
+ /**
+ * Persist data to database and cache.
+ *
+ * @param string $name Configuration field name
+ * @param mixed $payload Value to store
+ */
+ public function persistData(string $name, mixed $payload): void
+ {
+ $this->insert($name, $payload);
+ $this->putCache($name, $payload);
+ }
+
+ /**
+ * Insert or update configuration in database.
+ *
+ * Requires Core\Mod\Social module to be installed.
+ *
+ * @param string $name Configuration field name
+ * @param mixed $payload Value to store
+ */
+ public function insert(string $name, mixed $payload): void
+ {
+ if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
+ return;
+ }
+
+ \Core\Mod\Social\Models\Config::updateOrCreate(
+ ['name' => $name, 'group' => $this->group()],
+ ['payload' => $payload]
+ );
+ }
+
+ /**
+ * Get a configuration value.
+ *
+ * Checks cache first, then database, finally falls back to default from form().
+ * Requires Core\Mod\Social module for database lookup.
+ *
+ * @param string $name Configuration field name
+ */
+ public function get(string $name): mixed
+ {
+ return $this->getCache($name, function () use ($name) {
+ $default = Arr::get($this->form(), $name);
+
+ if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
+ return $default;
+ }
+
+ $payload = \Core\Mod\Social\Models\Config::get(
+ property: "{$this->group()}.{$name}",
+ default: $default
+ );
+
+ $this->putCache($name, $payload);
+
+ return $payload;
+ });
+ }
+
+ /**
+ * Get all configuration values for this group.
+ *
+ * @return array
+ */
+ public function all(): array
+ {
+ return Arr::map($this->form(), function ($_, $name) {
+ return $this->get($name);
+ });
+ }
+
+ /**
+ * Store a value in cache.
+ *
+ * @param string $name Configuration field name
+ * @param mixed $default Value to cache
+ */
+ public function putCache(string $name, mixed $default = null): void
+ {
+ Cache::put($this->resolveCacheKey($name), $default);
+ }
+
+ /**
+ * Retrieve a value from cache.
+ *
+ * @param string $name Configuration field name
+ * @param mixed $default Default value or closure to execute if not cached
+ */
+ public function getCache(string $name, mixed $default = null): mixed
+ {
+ return Cache::get($this->resolveCacheKey($name), $default);
+ }
+
+ /**
+ * Remove cache entries for this configuration group.
+ *
+ * @param string|null $name Specific field name, or null to clear all
+ */
+ public function forgetCache(?string $name = null): void
+ {
+ if (! $name) {
+ foreach (array_keys($this->form()) as $fieldName) {
+ $this->forgetCache($fieldName);
+ }
+
+ return;
+ }
+
+ Cache::forget($this->resolveCacheKey($name));
+ }
+
+ /**
+ * Build cache key for a configuration field.
+ *
+ * @param string $key Configuration field name
+ */
+ private function resolveCacheKey(string $key): string
+ {
+ $prefix = config('social.cache_prefix', 'social');
+
+ return "{$prefix}.configs.{$this->group()}.{$key}";
+ }
+}
diff --git a/src/Core/Config/ConfigExporter.php b/src/Core/Config/ConfigExporter.php
new file mode 100644
index 0000000..f80eaf4
--- /dev/null
+++ b/src/Core/Config/ConfigExporter.php
@@ -0,0 +1,536 @@
+exportJson($workspace);
+ * file_put_contents('config.json', $json);
+ *
+ * // Export to YAML
+ * $yaml = $exporter->exportYaml($workspace);
+ * file_put_contents('config.yaml', $yaml);
+ *
+ * // Import from JSON
+ * $result = $exporter->importJson(file_get_contents('config.json'), $workspace);
+ *
+ * // Import from YAML
+ * $result = $exporter->importYaml(file_get_contents('config.yaml'), $workspace);
+ * ```
+ *
+ * @see ConfigService For runtime config access
+ * @see ConfigVersioning For config versioning and rollback
+ */
+class ConfigExporter
+{
+ /**
+ * Current export format version.
+ */
+ protected const FORMAT_VERSION = '1.0';
+
+ /**
+ * Placeholder for sensitive values in exports.
+ */
+ protected const SENSITIVE_PLACEHOLDER = '***SENSITIVE***';
+
+ public function __construct(
+ protected ConfigService $config,
+ ) {}
+
+ /**
+ * Export config to JSON format.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $includeSensitive Include sensitive values (default: false)
+ * @param bool $includeKeys Include key definitions (default: true)
+ * @param string|null $category Filter by category (optional)
+ * @return string JSON string
+ */
+ public function exportJson(
+ ?object $workspace = null,
+ bool $includeSensitive = false,
+ bool $includeKeys = true,
+ ?string $category = null,
+ ): string {
+ $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category);
+
+ return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ /**
+ * Export config to YAML format.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $includeSensitive Include sensitive values (default: false)
+ * @param bool $includeKeys Include key definitions (default: true)
+ * @param string|null $category Filter by category (optional)
+ * @return string YAML string
+ */
+ public function exportYaml(
+ ?object $workspace = null,
+ bool $includeSensitive = false,
+ bool $includeKeys = true,
+ ?string $category = null,
+ ): string {
+ $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category);
+
+ return Yaml::dump($data, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
+ }
+
+ /**
+ * Build export data structure.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ protected function buildExportData(
+ ?object $workspace,
+ bool $includeSensitive,
+ bool $includeKeys,
+ ?string $category,
+ ): array {
+ $data = [
+ 'version' => self::FORMAT_VERSION,
+ 'exported_at' => now()->toIso8601String(),
+ 'scope' => [
+ 'type' => $workspace ? 'workspace' : 'system',
+ 'id' => $workspace?->id,
+ ],
+ ];
+
+ // Get profile for this scope
+ $profile = $this->getProfile($workspace);
+
+ if ($includeKeys) {
+ $data['keys'] = $this->exportKeys($category);
+ }
+
+ $data['values'] = $this->exportValues($profile, $includeSensitive, $category);
+
+ return $data;
+ }
+
+ /**
+ * Export key definitions.
+ *
+ * @return array>
+ */
+ protected function exportKeys(?string $category = null): array
+ {
+ $query = ConfigKey::query()->orderBy('category')->orderBy('code');
+
+ if ($category !== null) {
+ $escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category);
+ $query->where('code', 'LIKE', "{$escapedCategory}.%")
+ ->orWhere('category', $category);
+ }
+
+ return $query->get()->map(function (ConfigKey $key) {
+ return [
+ 'code' => $key->code,
+ 'type' => $key->type->value,
+ 'category' => $key->category,
+ 'description' => $key->description,
+ 'default_value' => $key->default_value,
+ 'is_sensitive' => $key->is_sensitive ?? false,
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Export config values.
+ *
+ * @return array>
+ */
+ protected function exportValues(?ConfigProfile $profile, bool $includeSensitive, ?string $category): array
+ {
+ if ($profile === null) {
+ return [];
+ }
+
+ $query = ConfigValue::query()
+ ->with('key')
+ ->where('profile_id', $profile->id);
+
+ $values = $query->get();
+
+ return $values
+ ->filter(function (ConfigValue $value) use ($category) {
+ if ($category === null) {
+ return true;
+ }
+ $key = $value->key;
+ if ($key === null) {
+ return false;
+ }
+
+ return str_starts_with($key->code, "{$category}.") || $key->category === $category;
+ })
+ ->map(function (ConfigValue $value) use ($includeSensitive) {
+ $key = $value->key;
+
+ // Mask sensitive values unless explicitly included
+ $displayValue = $value->value;
+ if ($key?->isSensitive() && ! $includeSensitive) {
+ $displayValue = self::SENSITIVE_PLACEHOLDER;
+ }
+
+ return [
+ 'key' => $key?->code ?? 'unknown',
+ 'value' => $displayValue,
+ 'locked' => $value->locked,
+ 'channel_id' => $value->channel_id,
+ ];
+ })
+ ->values()
+ ->toArray();
+ }
+
+ /**
+ * Import config from JSON format.
+ *
+ * @param string $json JSON string
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $dryRun Preview changes without applying
+ * @return ImportResult Import result with stats
+ *
+ * @throws \InvalidArgumentException If JSON is invalid
+ */
+ public function importJson(string $json, ?object $workspace = null, bool $dryRun = false): ImportResult
+ {
+ $data = json_decode($json, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new \InvalidArgumentException('Invalid JSON: '.json_last_error_msg());
+ }
+
+ return $this->importData($data, $workspace, $dryRun);
+ }
+
+ /**
+ * Import config from YAML format.
+ *
+ * @param string $yaml YAML string
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $dryRun Preview changes without applying
+ * @return ImportResult Import result with stats
+ *
+ * @throws \InvalidArgumentException If YAML is invalid
+ */
+ public function importYaml(string $yaml, ?object $workspace = null, bool $dryRun = false): ImportResult
+ {
+ try {
+ $data = Yaml::parse($yaml);
+ } catch (\Exception $e) {
+ throw new \InvalidArgumentException('Invalid YAML: '.$e->getMessage());
+ }
+
+ return $this->importData($data, $workspace, $dryRun);
+ }
+
+ /**
+ * Import config from parsed data.
+ *
+ * @param array $data Parsed import data
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $dryRun Preview changes without applying
+ */
+ protected function importData(array $data, ?object $workspace, bool $dryRun): ImportResult
+ {
+ $result = new ImportResult;
+
+ // Validate version
+ $version = $data['version'] ?? '1.0';
+ if (! $this->isVersionCompatible($version)) {
+ $result->addError("Incompatible export version: {$version} (expected {FORMAT_VERSION})");
+
+ return $result;
+ }
+
+ // Get or create profile for this scope
+ $profile = $this->getOrCreateProfile($workspace);
+
+ // Import keys if present
+ if (isset($data['keys']) && is_array($data['keys'])) {
+ $this->importKeys($data['keys'], $result, $dryRun);
+ }
+
+ // Import values if present
+ if (isset($data['values']) && is_array($data['values'])) {
+ $this->importValues($data['values'], $profile, $result, $dryRun);
+ }
+
+ // Re-prime config if changes were made
+ if (! $dryRun && $result->hasChanges()) {
+ $this->config->prime($workspace);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Import key definitions.
+ *
+ * @param array> $keys
+ */
+ protected function importKeys(array $keys, ImportResult $result, bool $dryRun): void
+ {
+ foreach ($keys as $keyData) {
+ $code = $keyData['code'] ?? null;
+ if ($code === null) {
+ $result->addSkipped('Key with no code');
+
+ continue;
+ }
+
+ try {
+ $type = ConfigType::tryFrom($keyData['type'] ?? 'string') ?? ConfigType::STRING;
+
+ $existing = ConfigKey::byCode($code);
+
+ if ($existing !== null) {
+ // Update existing key
+ if (! $dryRun) {
+ $existing->update([
+ 'type' => $type,
+ 'category' => $keyData['category'] ?? $existing->category,
+ 'description' => $keyData['description'] ?? $existing->description,
+ 'default_value' => $keyData['default_value'] ?? $existing->default_value,
+ 'is_sensitive' => $keyData['is_sensitive'] ?? $existing->is_sensitive,
+ ]);
+ }
+ $result->addUpdated($code, 'key');
+ } else {
+ // Create new key
+ if (! $dryRun) {
+ ConfigKey::create([
+ 'code' => $code,
+ 'type' => $type,
+ 'category' => $keyData['category'] ?? 'imported',
+ 'description' => $keyData['description'] ?? null,
+ 'default_value' => $keyData['default_value'] ?? null,
+ 'is_sensitive' => $keyData['is_sensitive'] ?? false,
+ ]);
+ }
+ $result->addCreated($code, 'key');
+ }
+ } catch (\Exception $e) {
+ $result->addError("Failed to import key '{$code}': ".$e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * Import config values.
+ *
+ * @param array> $values
+ */
+ protected function importValues(array $values, ConfigProfile $profile, ImportResult $result, bool $dryRun): void
+ {
+ foreach ($values as $valueData) {
+ $keyCode = $valueData['key'] ?? null;
+ if ($keyCode === null) {
+ $result->addSkipped('Value with no key');
+
+ continue;
+ }
+
+ // Skip sensitive placeholders
+ if ($valueData['value'] === self::SENSITIVE_PLACEHOLDER) {
+ $result->addSkipped("{$keyCode} (sensitive placeholder)");
+
+ continue;
+ }
+
+ try {
+ $key = ConfigKey::byCode($keyCode);
+ if ($key === null) {
+ $result->addSkipped("{$keyCode} (key not found)");
+
+ continue;
+ }
+
+ $channelId = $valueData['channel_id'] ?? null;
+ $existing = ConfigValue::findValue($profile->id, $key->id, $channelId);
+
+ if ($existing !== null) {
+ // Update existing value
+ if (! $dryRun) {
+ $existing->value = $valueData['value'];
+ $existing->locked = $valueData['locked'] ?? false;
+ $existing->save();
+ }
+ $result->addUpdated($keyCode, 'value');
+ } else {
+ // Create new value
+ if (! $dryRun) {
+ $value = new ConfigValue;
+ $value->profile_id = $profile->id;
+ $value->key_id = $key->id;
+ $value->channel_id = $channelId;
+ $value->value = $valueData['value'];
+ $value->locked = $valueData['locked'] ?? false;
+ $value->save();
+ }
+ $result->addCreated($keyCode, 'value');
+ }
+ } catch (\Exception $e) {
+ $result->addError("Failed to import value '{$keyCode}': ".$e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * Check if export version is compatible.
+ */
+ protected function isVersionCompatible(string $version): bool
+ {
+ // For now, only support exact version match
+ // Can be extended to support backward compatibility
+ $supported = ['1.0'];
+
+ return in_array($version, $supported, true);
+ }
+
+ /**
+ * Get profile for a workspace (or system).
+ */
+ protected function getProfile(?object $workspace): ?ConfigProfile
+ {
+ if ($workspace !== null) {
+ return ConfigProfile::forWorkspace($workspace->id);
+ }
+
+ return ConfigProfile::system();
+ }
+
+ /**
+ * Get or create profile for a workspace (or system).
+ */
+ protected function getOrCreateProfile(?object $workspace): ConfigProfile
+ {
+ if ($workspace !== null) {
+ return ConfigProfile::ensureWorkspace($workspace->id);
+ }
+
+ return ConfigProfile::ensureSystem();
+ }
+
+ /**
+ * Export config to a file.
+ *
+ * @param string $path File path (extension determines format)
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $includeSensitive Include sensitive values
+ *
+ * @throws \RuntimeException If file cannot be written
+ */
+ public function exportToFile(
+ string $path,
+ ?object $workspace = null,
+ bool $includeSensitive = false,
+ ): void {
+ $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ $content = match ($extension) {
+ 'yaml', 'yml' => $this->exportYaml($workspace, $includeSensitive),
+ default => $this->exportJson($workspace, $includeSensitive),
+ };
+
+ $result = file_put_contents($path, $content);
+
+ if ($result === false) {
+ throw new \RuntimeException("Failed to write config export to: {$path}");
+ }
+ }
+
+ /**
+ * Import config from a file.
+ *
+ * @param string $path File path (extension determines format)
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $dryRun Preview changes without applying
+ * @return ImportResult Import result with stats
+ *
+ * @throws \RuntimeException If file cannot be read
+ */
+ public function importFromFile(
+ string $path,
+ ?object $workspace = null,
+ bool $dryRun = false,
+ ): ImportResult {
+ if (! file_exists($path)) {
+ throw new \RuntimeException("Config file not found: {$path}");
+ }
+
+ $content = file_get_contents($path);
+ if ($content === false) {
+ throw new \RuntimeException("Failed to read config file: {$path}");
+ }
+
+ $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ return match ($extension) {
+ 'yaml', 'yml' => $this->importYaml($content, $workspace, $dryRun),
+ default => $this->importJson($content, $workspace, $dryRun),
+ };
+ }
+}
diff --git a/src/Core/Config/ConfigResolver.php b/src/Core/Config/ConfigResolver.php
new file mode 100644
index 0000000..2c5e364
--- /dev/null
+++ b/src/Core/Config/ConfigResolver.php
@@ -0,0 +1,639 @@
+
+ */
+ protected static array $values = [];
+
+ /**
+ * Whether the hash has been loaded.
+ */
+ protected static bool $loaded = false;
+
+ /**
+ * Registered virtual providers.
+ *
+ * Supports both ConfigProvider instances and callable functions.
+ *
+ * @var array
+ */
+ protected array $providers = [];
+
+ // =========================================================================
+ // THE HASH
+ // =========================================================================
+
+ /**
+ * Get a value from the hash.
+ */
+ public static function get(string $key): mixed
+ {
+ return static::$values[$key] ?? null;
+ }
+
+ /**
+ * Set a value in the hash.
+ */
+ public static function set(string $key, mixed $value): void
+ {
+ static::$values[$key] = $value;
+ }
+
+ /**
+ * Check if a value exists in the hash.
+ */
+ public static function has(string $key): bool
+ {
+ return array_key_exists($key, static::$values);
+ }
+
+ /**
+ * Clear keys matching a pattern (bi-directional).
+ */
+ public static function clear(string $pattern): void
+ {
+ static::$values = array_filter(
+ static::$values,
+ fn ($k) => ! str_contains($k, $pattern),
+ ARRAY_FILTER_USE_KEY
+ );
+ }
+
+ /**
+ * Clear entire hash.
+ */
+ public static function clearAll(): void
+ {
+ static::$values = [];
+ static::$loaded = false;
+ }
+
+ /**
+ * Get all values (for debugging).
+ *
+ * @return array
+ */
+ public static function all(): array
+ {
+ return static::$values;
+ }
+
+ /**
+ * Check if hash has been loaded.
+ */
+ public static function isLoaded(): bool
+ {
+ return static::$loaded;
+ }
+
+ /**
+ * Mark hash as loaded.
+ */
+ public static function markLoaded(): void
+ {
+ static::$loaded = true;
+ }
+
+ // =========================================================================
+ // RESOLUTION ENGINE (only runs during lazy prime, not normal reads)
+ // =========================================================================
+
+ /**
+ * Resolve a single key for a workspace and optional channel.
+ *
+ * NOTE: This is the expensive path - only called when lazy-priming.
+ * Normal reads hit the hash directly via ConfigService.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param string|Channel|null $channel Channel code or object
+ */
+ public function resolve(
+ string $keyCode,
+ ?object $workspace = null,
+ string|Channel|null $channel = null,
+ ): ConfigResult {
+ // Get key definition (DB query - only during resolve, not normal reads)
+ $key = ConfigKey::byCode($keyCode);
+
+ if ($key === null) {
+ // Try JSON sub-key extraction
+ return $this->resolveJsonSubKey($keyCode, $workspace, $channel);
+ }
+
+ // Build chains
+ $profileChain = $this->buildProfileChain($workspace);
+ $channelChain = $this->buildChannelChain($channel, $workspace);
+
+ // Batch load all values for this key
+ $values = $this->batchLoadValues(
+ $key->id,
+ $profileChain->pluck('id')->all(),
+ $channelChain->pluck('id')->all()
+ );
+
+ // Build resolution matrix (profile × channel combinations)
+ $matrix = $this->buildResolutionMatrix($profileChain, $channelChain);
+
+ // First pass: check for FINAL locks (from least specific scope)
+ $lockedResult = $this->findFinalLock($matrix, $values, $keyCode, $key);
+ if ($lockedResult !== null) {
+ return $lockedResult;
+ }
+
+ // Second pass: find most specific value
+ foreach ($matrix as $combo) {
+ $value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
+
+ if ($value !== null) {
+ return ConfigResult::found(
+ key: $keyCode,
+ value: $value->value,
+ type: $key->type,
+ locked: false,
+ resolvedFrom: $combo['scope_type'],
+ profileId: $combo['profile_id'],
+ channelId: $combo['channel_id'],
+ );
+ }
+ }
+
+ // Check virtual providers
+ $virtualValue = $this->resolveFromProviders($keyCode, $workspace, $channel);
+ if ($virtualValue !== null) {
+ return ConfigResult::virtual(
+ key: $keyCode,
+ value: $virtualValue,
+ type: $key->type,
+ );
+ }
+
+ // No value found - return default
+ return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type);
+ }
+
+ /**
+ * Maximum recursion depth for JSON sub-key resolution.
+ */
+ protected const MAX_SUBKEY_DEPTH = 10;
+
+ /**
+ * Current recursion depth for sub-key resolution.
+ */
+ protected int $subKeyDepth = 0;
+
+ /**
+ * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
+ */
+ /**
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ protected function resolveJsonSubKey(
+ string $keyCode,
+ ?object $workspace,
+ string|Channel|null $channel,
+ ): ConfigResult {
+ // Guard against stack overflow from deep nesting
+ if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) {
+ return ConfigResult::unconfigured($keyCode);
+ }
+
+ $this->subKeyDepth++;
+
+ try {
+ $parts = explode('.', $keyCode);
+
+ // Try progressively shorter parent keys
+ for ($i = count($parts) - 1; $i > 0; $i--) {
+ $parentKey = implode('.', array_slice($parts, 0, $i));
+ $subPath = implode('.', array_slice($parts, $i));
+
+ $parentResult = $this->resolve($parentKey, $workspace, $channel);
+
+ if ($parentResult->found && is_array($parentResult->value)) {
+ $subValue = data_get($parentResult->value, $subPath);
+
+ if ($subValue !== null) {
+ return ConfigResult::found(
+ key: $keyCode,
+ value: $subValue,
+ type: $parentResult->type, // Inherit parent type
+ locked: $parentResult->locked,
+ resolvedFrom: $parentResult->resolvedFrom,
+ profileId: $parentResult->profileId,
+ channelId: $parentResult->channelId,
+ );
+ }
+ }
+ }
+
+ return ConfigResult::unconfigured($keyCode);
+ } finally {
+ $this->subKeyDepth--;
+ }
+ }
+
+ /**
+ * Build the channel inheritance chain.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return Collection
+ */
+ public function buildChannelChain(
+ string|Channel|null $channel,
+ ?object $workspace = null,
+ ): Collection {
+ $chain = new Collection;
+
+ if ($channel === null) {
+ // No channel specified - just null (applies to all)
+ $chain->push(null);
+
+ return $chain;
+ }
+
+ // Resolve channel code to model
+ if (is_string($channel)) {
+ $channel = Channel::byCode($channel, $workspace?->id);
+ }
+
+ if ($channel !== null) {
+ // Add channel inheritance chain
+ $chain = $chain->merge($channel->inheritanceChain());
+ }
+
+ // Always include null (all-channels fallback)
+ $chain->push(null);
+
+ return $chain;
+ }
+
+ /**
+ * Batch load all values for a key across profiles and channels.
+ *
+ * @param array $profileIds
+ * @param array $channelIds
+ * @return Collection
+ */
+ protected function batchLoadValues(int $keyId, array $profileIds, array $channelIds): Collection
+ {
+ // Separate null from actual channel IDs for query
+ $actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
+
+ return ConfigValue::where('key_id', $keyId)
+ ->whereIn('profile_id', $profileIds)
+ ->where(function ($query) use ($actualChannelIds) {
+ $query->whereNull('channel_id');
+ if (! empty($actualChannelIds)) {
+ $query->orWhereIn('channel_id', $actualChannelIds);
+ }
+ })
+ ->get();
+ }
+
+ /**
+ * Build resolution matrix (profile × channel combinations).
+ *
+ * Order: most specific first (workspace + specific channel)
+ * to least specific (system + null channel).
+ *
+ * @return array
+ */
+ protected function buildResolutionMatrix(Collection $profileChain, Collection $channelChain): array
+ {
+ $matrix = [];
+
+ foreach ($profileChain as $profile) {
+ foreach ($channelChain as $channel) {
+ $matrix[] = [
+ 'profile_id' => $profile->id,
+ 'channel_id' => $channel?->id,
+ 'scope_type' => $profile->scope_type,
+ ];
+ }
+ }
+
+ return $matrix;
+ }
+
+ /**
+ * Find a FINAL lock in the resolution matrix.
+ *
+ * Checks from least specific (system) to find any lock that
+ * would prevent more specific values from being used.
+ */
+ protected function findFinalLock(
+ array $matrix,
+ Collection $values,
+ string $keyCode,
+ ConfigKey $key,
+ ): ?ConfigResult {
+ // Reverse to check from least specific (system)
+ $reversed = array_reverse($matrix);
+
+ foreach ($reversed as $combo) {
+ $value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
+
+ if ($value !== null && $value->isLocked()) {
+ return ConfigResult::found(
+ key: $keyCode,
+ value: $value->value,
+ type: $key->type,
+ locked: true,
+ resolvedFrom: $combo['scope_type'],
+ profileId: $combo['profile_id'],
+ channelId: $combo['channel_id'],
+ );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Find a value in the batch-loaded collection.
+ */
+ protected function findValueInBatch(Collection $values, int $profileId, ?int $channelId): ?ConfigValue
+ {
+ return $values->first(function (ConfigValue $value) use ($profileId, $channelId) {
+ return $value->profile_id === $profileId
+ && $value->channel_id === $channelId;
+ });
+ }
+
+ /**
+ * Register a virtual provider for a key pattern.
+ *
+ * Providers supply values from module data without database storage.
+ * Accepts either a ConfigProvider instance or a callable.
+ *
+ * @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance
+ * @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed
+ */
+ public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void
+ {
+ // Support both new interface-based and legacy callable patterns
+ if ($patternOrProvider instanceof ConfigProvider) {
+ $this->providers[$patternOrProvider->pattern()] = $patternOrProvider;
+ } elseif ($provider !== null) {
+ $this->providers[$patternOrProvider] = $provider;
+ }
+ }
+
+ /**
+ * Resolve value from virtual providers.
+ *
+ * Supports both ConfigProvider instances and legacy callables.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ public function resolveFromProviders(
+ string $keyCode,
+ ?object $workspace,
+ string|Channel|null $channel,
+ ): mixed {
+ foreach ($this->providers as $pattern => $provider) {
+ if ($this->matchesPattern($keyCode, $pattern)) {
+ // Support both ConfigProvider interface and legacy callable
+ $value = $provider instanceof ConfigProvider
+ ? $provider->resolve($keyCode, $workspace, $channel)
+ : $provider($keyCode, $workspace, $channel);
+
+ if ($value !== null) {
+ return $value;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a key matches a provider pattern.
+ */
+ protected function matchesPattern(string $key, string $pattern): bool
+ {
+ if ($pattern === $key) {
+ return true;
+ }
+
+ // Convert pattern to regex (e.g., "bio.*" → "^bio\..*$")
+ $regex = '/^'.str_replace(['.', '*'], ['\.', '.*'], $pattern).'$/';
+
+ return (bool) preg_match($regex, $key);
+ }
+
+ /**
+ * Resolve all keys for a workspace.
+ *
+ * NOTE: Only called during prime, not normal reads.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return array
+ */
+ public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array
+ {
+ $results = [];
+
+ // Query all keys from DB (only during prime)
+ foreach (ConfigKey::all() as $key) {
+ $results[$key->code] = $this->resolve($key->code, $workspace, $channel);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Resolve all keys in a category.
+ *
+ * NOTE: Only called during prime, not normal reads.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return array
+ */
+ public function resolveCategory(
+ string $category,
+ ?object $workspace = null,
+ string|Channel|null $channel = null,
+ ): array {
+ $results = [];
+
+ // Query keys by category from DB (only during prime)
+ foreach (ConfigKey::where('category', $category)->get() as $key) {
+ $results[$key->code] = $this->resolve($key->code, $workspace, $channel);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Build the profile chain for resolution.
+ *
+ * Returns profiles ordered from most specific (workspace) to least (system).
+ * Chain: workspace → org → system
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return Collection
+ */
+ public function buildProfileChain(?object $workspace = null): Collection
+ {
+ $chain = new Collection;
+
+ // Workspace profiles (most specific)
+ if ($workspace !== null) {
+ $workspaceProfiles = ConfigProfile::forScope(ScopeType::WORKSPACE, $workspace->id);
+ $chain = $chain->merge($workspaceProfiles);
+
+ // Org layer - workspace belongs to organisation
+ $orgId = $this->resolveOrgId($workspace);
+ if ($orgId !== null) {
+ $orgProfiles = ConfigProfile::forScope(ScopeType::ORG, $orgId);
+ $chain = $chain->merge($orgProfiles);
+ }
+ }
+
+ // System profiles (least specific)
+ $systemProfiles = ConfigProfile::forScope(ScopeType::SYSTEM, null);
+ $chain = $chain->merge($systemProfiles);
+
+ // Add parent profile inheritance
+ $chain = $this->expandParentProfiles($chain);
+
+ return $chain;
+ }
+
+ /**
+ * Resolve organisation ID from workspace.
+ *
+ * Stub for now - will connect to Tenant module when org model exists.
+ * Organisation = multi-workspace grouping (agency accounts, teams).
+ *
+ * @param object|null $workspace Workspace model instance or null
+ */
+ protected function resolveOrgId(?object $workspace): ?int
+ {
+ if ($workspace === null) {
+ return null;
+ }
+
+ // Workspace::organisation_id when model has org support
+ // For now, return null (no org layer)
+ return $workspace->organisation_id ?? null;
+ }
+
+ /**
+ * Expand chain to include parent profiles.
+ *
+ * @param Collection $chain
+ * @return Collection
+ */
+ protected function expandParentProfiles(Collection $chain): Collection
+ {
+ $expanded = new Collection;
+ $seen = [];
+
+ foreach ($chain as $profile) {
+ $this->addProfileWithParents($profile, $expanded, $seen);
+ }
+
+ return $expanded;
+ }
+
+ /**
+ * Add a profile and its parents to the chain.
+ *
+ * @param Collection $chain
+ * @param array $seen
+ */
+ protected function addProfileWithParents(ConfigProfile $profile, Collection $chain, array &$seen): void
+ {
+ if (isset($seen[$profile->id])) {
+ return;
+ }
+
+ $seen[$profile->id] = true;
+ $chain->push($profile);
+
+ // Follow parent chain
+ if ($profile->parent_profile_id !== null) {
+ $parent = $profile->parent;
+
+ if ($parent !== null) {
+ $this->addProfileWithParents($parent, $chain, $seen);
+ }
+ }
+ }
+
+ /**
+ * Check if a key prefix is configured.
+ *
+ * Optimised to use EXISTS query instead of resolving each key.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ public function isPrefixConfigured(
+ string $prefix,
+ ?object $workspace = null,
+ string|Channel|null $channel = null,
+ ): bool {
+ // Get profile IDs for this workspace
+ $profileChain = $this->buildProfileChain($workspace);
+ $profileIds = $profileChain->pluck('id')->all();
+
+ // Get channel IDs
+ $channelChain = $this->buildChannelChain($channel, $workspace);
+ $channelIds = $channelChain->map(fn ($c) => $c?->id)->all();
+ $actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
+
+ // Single EXISTS query
+ return ConfigValue::whereIn('profile_id', $profileIds)
+ ->where(function ($query) use ($actualChannelIds) {
+ $query->whereNull('channel_id');
+ if (! empty($actualChannelIds)) {
+ $query->orWhereIn('channel_id', $actualChannelIds);
+ }
+ })
+ ->whereHas('key', function ($query) use ($prefix) {
+ $query->where('code', 'LIKE', "{$prefix}.%");
+ })
+ ->exists();
+ }
+}
diff --git a/src/Core/Config/ConfigResult.php b/src/Core/Config/ConfigResult.php
new file mode 100644
index 0000000..b92f5ab
--- /dev/null
+++ b/src/Core/Config/ConfigResult.php
@@ -0,0 +1,211 @@
+cast($value),
+ type: $type,
+ found: true,
+ locked: $locked,
+ virtual: false,
+ resolvedFrom: $resolvedFrom,
+ profileId: $profileId,
+ channelId: $channelId,
+ );
+ }
+
+ /**
+ * Create a result from a virtual provider.
+ */
+ public static function virtual(
+ string $key,
+ mixed $value,
+ ConfigType $type,
+ ): self {
+ return new self(
+ key: $key,
+ value: $type->cast($value),
+ type: $type,
+ found: true,
+ locked: false,
+ virtual: true,
+ );
+ }
+
+ /**
+ * Create a not-found result with default value.
+ */
+ public static function notFound(string $key, mixed $defaultValue, ConfigType $type): self
+ {
+ return new self(
+ key: $key,
+ value: $type->cast($defaultValue),
+ type: $type,
+ found: false,
+ locked: false,
+ );
+ }
+
+ /**
+ * Create a result for unconfigured key.
+ */
+ public static function unconfigured(string $key): self
+ {
+ return new self(
+ key: $key,
+ value: null,
+ type: ConfigType::STRING,
+ found: false,
+ locked: false,
+ );
+ }
+
+ /**
+ * Check if the key was found (has a value).
+ */
+ public function isConfigured(): bool
+ {
+ return $this->found && $this->value !== null;
+ }
+
+ /**
+ * Check if the value is locked (FINAL).
+ */
+ public function isLocked(): bool
+ {
+ return $this->locked;
+ }
+
+ /**
+ * Check if the value came from a virtual provider.
+ */
+ public function isVirtual(): bool
+ {
+ return $this->virtual;
+ }
+
+ /**
+ * Get the value, with optional fallback.
+ */
+ public function get(mixed $default = null): mixed
+ {
+ return $this->value ?? $default;
+ }
+
+ /**
+ * Get value as string.
+ */
+ public function string(string $default = ''): string
+ {
+ return (string) ($this->value ?? $default);
+ }
+
+ /**
+ * Get value as integer.
+ */
+ public function int(int $default = 0): int
+ {
+ return (int) ($this->value ?? $default);
+ }
+
+ /**
+ * Get value as boolean.
+ */
+ public function bool(bool $default = false): bool
+ {
+ return (bool) ($this->value ?? $default);
+ }
+
+ /**
+ * Get value as array.
+ */
+ public function array(array $default = []): array
+ {
+ if ($this->value === null) {
+ return $default;
+ }
+
+ return is_array($this->value) ? $this->value : $default;
+ }
+
+ /**
+ * Convert to array for caching.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'key' => $this->key,
+ 'value' => $this->value,
+ 'type' => $this->type->value,
+ 'found' => $this->found,
+ 'locked' => $this->locked,
+ 'virtual' => $this->virtual,
+ 'resolved_from' => $this->resolvedFrom?->value,
+ 'profile_id' => $this->profileId,
+ 'channel_id' => $this->channelId,
+ ];
+ }
+
+ /**
+ * Reconstruct from cached array.
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ key: $data['key'],
+ value: $data['value'],
+ type: ConfigType::from($data['type']),
+ found: $data['found'],
+ locked: $data['locked'],
+ virtual: $data['virtual'] ?? false,
+ resolvedFrom: ($data['resolved_from'] ?? null) ? ScopeType::from($data['resolved_from']) : null,
+ profileId: $data['profile_id'] ?? null,
+ channelId: $data['channel_id'] ?? null,
+ );
+ }
+}
diff --git a/src/Core/Config/ConfigService.php b/src/Core/Config/ConfigService.php
new file mode 100644
index 0000000..e5d50f0
--- /dev/null
+++ b/src/Core/Config/ConfigService.php
@@ -0,0 +1,822 @@
+get('cdn.bunny.api_key', $workspace);
+ * $config->set('cdn.bunny.api_key', 'new-value', $profile);
+ *
+ * // Module Boot.php - provide runtime value (no DB)
+ * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
+ * ```
+ *
+ * ## Cache Invalidation Strategy
+ *
+ * The Config module uses a two-tier caching system:
+ *
+ * ### Tier 1: In-Memory Hash (Process-Scoped)
+ * - `ConfigResolver::$values` - Static array holding all config values
+ * - Cleared on process termination (dies with the request)
+ * - Cleared explicitly via `ConfigResolver::clearAll()` or `ConfigResolver::clear($key)`
+ *
+ * ### Tier 2: Database Resolved Table (Persistent)
+ * - `config_resolved` table - Materialised config resolution
+ * - Survives across requests, shared between all processes
+ * - Cleared via `ConfigResolved::clearScope()`, `clearWorkspace()`, or `clearKey()`
+ *
+ * ### Invalidation Triggers
+ *
+ * 1. **On Config Change (`set()`):**
+ * - Clears the specific key from both hash and database
+ * - Re-primes the key for the affected scope
+ * - Dispatches `ConfigChanged` event for module hooks
+ *
+ * 2. **On Lock/Unlock:**
+ * - Re-primes the key (lock affects all child scopes)
+ * - Dispatches `ConfigLocked` event
+ *
+ * 3. **Manual Invalidation:**
+ * - `invalidateWorkspace($workspace)` - Clears all config for a workspace
+ * - `invalidateKey($key)` - Clears a key across all scopes
+ * - Both dispatch `ConfigInvalidated` event
+ *
+ * 4. **Full Re-prime:**
+ * - `prime($workspace)` - Clears and recomputes all config for a scope
+ * - `primeAll()` - Primes system config + all workspaces (scheduled job)
+ *
+ * ### Lazy Loading
+ *
+ * When a key is not found in the hash:
+ * 1. If scope not loaded, `loadScope()` loads all resolved values for the scope
+ * 2. If still not found, `resolve()` computes and stores the value
+ * 3. Result is stored in both hash (for current request) and database (persistent)
+ *
+ * ### Events for Module Integration
+ *
+ * Modules can listen to cache events to refresh their own caches:
+ * - `ConfigChanged` - Fired when a config value is set/updated
+ * - `ConfigLocked` - Fired when a config value is locked
+ * - `ConfigInvalidated` - Fired when cache is manually invalidated
+ *
+ * ```php
+ * // In your module's Boot.php
+ * public static array $listens = [
+ * ConfigChanged::class => 'onConfigChanged',
+ * ];
+ *
+ * public function onConfigChanged(ConfigChanged $event): void
+ * {
+ * if ($event->keyCode === 'mymodule.api_key') {
+ * $this->refreshApiClient();
+ * }
+ * }
+ * ```
+ *
+ * @see ConfigResolver For the caching hash implementation
+ * @see ConfigResolved For the database cache model
+ * @see ConfigChanged Event fired on config changes
+ * @see ConfigInvalidated Event fired on cache invalidation
+ */
+class ConfigService
+{
+ /**
+ * Current workspace context (Workspace model instance or null for system scope).
+ */
+ protected ?object $workspace = null;
+
+ protected ?Channel $channel = null;
+
+ public function __construct(
+ protected ConfigResolver $resolver,
+ ) {}
+
+ /**
+ * Set the current context (called by middleware).
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ public function setContext(?object $workspace, ?Channel $channel = null): void
+ {
+ $this->workspace = $workspace;
+ $this->channel = $channel;
+ }
+
+ /**
+ * Get current workspace context.
+ *
+ * @return object|null Workspace model instance or null
+ */
+ public function getWorkspace(): ?object
+ {
+ return $this->workspace;
+ }
+
+ /**
+ * Get a config value.
+ *
+ * Context (workspace/channel) is set by middleware via setContext().
+ * This is just key/value - simple.
+ */
+ public function get(string $key, mixed $default = null): mixed
+ {
+ $result = $this->resolve($key, $this->workspace, $this->channel);
+
+ return $result->get($default);
+ }
+
+ /**
+ * Get config for a specific workspace (admin use only).
+ *
+ * Use this when you need another workspace's settings - requires explicit intent.
+ *
+ * @param object $workspace Workspace model instance
+ */
+ public function getForWorkspace(string $key, object $workspace, mixed $default = null): mixed
+ {
+ $result = $this->resolve($key, $workspace, null);
+
+ return $result->get($default);
+ }
+
+ /**
+ * Get a resolved ConfigResult.
+ *
+ * Read path:
+ * 1. Hash lookup (O(1))
+ * 2. Lazy load scope if not loaded (1 query)
+ * 3. Hash lookup again
+ * 4. Compute via resolver if still not found (lazy prime)
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param string|Channel|null $channel Channel code or object
+ */
+ public function resolve(
+ string $key,
+ ?object $workspace = null,
+ string|Channel|null $channel = null,
+ ): ConfigResult {
+ $workspaceId = $workspace?->id;
+ $channelId = $this->resolveChannelId($channel, $workspace);
+
+ // 1. Check hash (O(1))
+ if (ConfigResolver::has($key)) {
+ // Get full result from ConfigResolved (indexed lookup with metadata)
+ $resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
+
+ if ($resolved !== null) {
+ return $resolved->toResult();
+ }
+
+ // Fallback: value in hash but not in DB (runtime provided)
+ return ConfigResult::found(
+ key: $key,
+ value: ConfigResolver::get($key),
+ type: ConfigType::STRING,
+ locked: false,
+ );
+ }
+
+ // 2. Scope not loaded - lazy load entire scope
+ if (! ConfigResolver::isLoaded()) {
+ $this->loadScope($workspaceId, $channelId);
+
+ // Check hash again
+ if (ConfigResolver::has($key)) {
+ $resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
+
+ if ($resolved !== null) {
+ return $resolved->toResult();
+ }
+
+ return ConfigResult::found(
+ key: $key,
+ value: ConfigResolver::get($key),
+ type: ConfigType::STRING,
+ locked: false,
+ );
+ }
+ }
+
+ // 3. Try JSON sub-key extraction
+ $subKeyResult = $this->resolveJsonSubKey($key, $workspace, $channel);
+ if ($subKeyResult->found) {
+ return $subKeyResult;
+ }
+
+ // 4. Check virtual providers
+ $virtualValue = $this->resolver->resolveFromProviders($key, $workspace, $channel);
+ if ($virtualValue !== null) {
+ $keyModel = ConfigKey::byCode($key);
+ $type = $keyModel?->type ?? ConfigType::STRING;
+
+ // Store in hash for next read
+ ConfigResolver::set($key, $virtualValue);
+
+ return ConfigResult::virtual(
+ key: $key,
+ value: $virtualValue,
+ type: $type,
+ );
+ }
+
+ // 5. Lazy prime: compute via resolver
+ $result = $this->resolver->resolve($key, $workspace, $channel);
+
+ // Store in hash
+ ConfigResolver::set($key, $result->value);
+
+ // Store in DB for future requests
+ if ($result->isConfigured()) {
+ ConfigResolved::store(
+ keyCode: $key,
+ value: $result->value,
+ type: $result->type,
+ workspaceId: $workspaceId,
+ channelId: $channelId,
+ locked: $result->locked,
+ sourceProfileId: $result->profileId,
+ sourceChannelId: $result->channelId,
+ virtual: $result->virtual,
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Load a scope into the hash from database.
+ */
+ protected function loadScope(?int $workspaceId, ?int $channelId): void
+ {
+ $resolved = ConfigResolved::forScope($workspaceId, $channelId);
+
+ foreach ($resolved as $row) {
+ ConfigResolver::set($row->key_code, $row->getTypedValue());
+ }
+
+ ConfigResolver::markLoaded();
+ }
+
+ /**
+ * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ protected function resolveJsonSubKey(
+ string $keyCode,
+ ?object $workspace,
+ string|Channel|null $channel,
+ ): ConfigResult {
+ $parts = explode('.', $keyCode);
+
+ // Try progressively shorter parent keys
+ for ($i = count($parts) - 1; $i > 0; $i--) {
+ $parentKey = implode('.', array_slice($parts, 0, $i));
+ $subPath = implode('.', array_slice($parts, $i));
+
+ $workspaceId = $workspace?->id;
+ $channelId = $this->resolveChannelId($channel, $workspace);
+
+ $resolved = ConfigResolved::lookup($parentKey, $workspaceId, $channelId);
+
+ if ($resolved !== null && is_array($resolved->value)) {
+ $subValue = data_get($resolved->value, $subPath);
+
+ if ($subValue !== null) {
+ $result = $resolved->toResult();
+
+ return new ConfigResult(
+ key: $keyCode,
+ value: $subValue,
+ type: $result->type,
+ found: true,
+ locked: $result->locked,
+ virtual: $result->virtual,
+ resolvedFrom: $result->resolvedFrom,
+ profileId: $result->profileId,
+ channelId: $result->channelId,
+ );
+ }
+ }
+ }
+
+ return ConfigResult::unconfigured($keyCode);
+ }
+
+ /**
+ * Check if a key (or prefix) is configured.
+ *
+ * Uses current context set by middleware.
+ */
+ public function isConfigured(string $keyOrPrefix): bool
+ {
+ $workspaceId = $this->workspace?->id;
+ $channelId = $this->channel?->id;
+
+ // Check if it's a direct key
+ $resolved = ConfigResolved::lookup($keyOrPrefix, $workspaceId, $channelId);
+ if ($resolved !== null && $resolved->value !== null) {
+ return true;
+ }
+
+ // Check as prefix - single EXISTS query
+ // Escape LIKE wildcards to prevent unintended pattern matching
+ $escapedPrefix = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $keyOrPrefix);
+
+ return ConfigResolved::where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->where('key_code', 'LIKE', "{$escapedPrefix}.%")
+ ->whereNotNull('value')
+ ->exists();
+ }
+
+ /**
+ * Set a config value.
+ *
+ * Updates config_values (source of truth), then re-primes affected scope.
+ * Fires ConfigChanged event for invalidation hooks.
+ *
+ * @param string|Channel|null $channel Channel code or object
+ *
+ * @throws \InvalidArgumentException If key is unknown or value type is invalid
+ */
+ public function set(
+ string $keyCode,
+ mixed $value,
+ ConfigProfile $profile,
+ bool $locked = false,
+ string|Channel|null $channel = null,
+ ): void {
+ // Get key from DB (only during set, not reads)
+ $key = ConfigKey::byCode($keyCode);
+
+ if ($key === null) {
+ throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
+ }
+
+ // Validate value type against schema
+ $this->validateValueType($value, $key->type, $keyCode);
+
+ $channelId = $this->resolveChannelId($channel, null);
+
+ // Capture previous value for event
+ $previousValue = ConfigValue::findValue($profile->id, $key->id, $channelId)?->value;
+
+ // Update source of truth
+ ConfigValue::setValue($profile->id, $key->id, $value, $locked, null, $channelId);
+
+ // Re-prime affected scope
+ $workspaceId = match ($profile->scope_type) {
+ Enums\ScopeType::WORKSPACE => $profile->scope_id,
+ default => null,
+ };
+
+ $this->primeKey($keyCode, $workspaceId, $channelId);
+
+ // Fire event for module hooks
+ ConfigChanged::dispatch($keyCode, $value, $previousValue, $profile, $channelId);
+ }
+
+ /**
+ * Lock a config value (FINAL - child cannot override).
+ * Fires ConfigLocked event.
+ */
+ public function lock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
+ {
+ // Get key from DB (only during lock, not reads)
+ $key = ConfigKey::byCode($keyCode);
+
+ if ($key === null) {
+ throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
+ }
+
+ $channelId = $this->resolveChannelId($channel, null);
+ $value = ConfigValue::findValue($profile->id, $key->id, $channelId);
+
+ if ($value === null) {
+ throw new \InvalidArgumentException("No value set for {$keyCode} in profile {$profile->id}");
+ }
+
+ $value->update(['locked' => true]);
+
+ // Re-prime - lock affects all child scopes
+ $this->primeKey($keyCode);
+
+ // Fire event for module hooks
+ ConfigLocked::dispatch($keyCode, $profile, $channelId);
+ }
+
+ /**
+ * Unlock a config value.
+ */
+ public function unlock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
+ {
+ // Get key from DB (only during unlock, not reads)
+ $key = ConfigKey::byCode($keyCode);
+
+ if ($key === null) {
+ return;
+ }
+
+ $channelId = $this->resolveChannelId($channel, null);
+ $value = ConfigValue::findValue($profile->id, $key->id, $channelId);
+
+ if ($value === null) {
+ return;
+ }
+
+ $value->update(['locked' => false]);
+
+ // Re-prime
+ $this->primeKey($keyCode);
+ }
+
+ /**
+ * Register a virtual provider.
+ *
+ * Virtual providers supply config values from module data
+ * without database storage.
+ *
+ * @param string $pattern Key pattern (supports * wildcard)
+ * @param callable $provider fn(string $key, ?Workspace, ?Channel): mixed
+ */
+ public function virtual(string $pattern, callable $provider): void
+ {
+ $this->resolver->registerProvider($pattern, $provider);
+ }
+
+ /**
+ * Get all config values for a workspace.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return array
+ */
+ public function all(?object $workspace = null, string|Channel|null $channel = null): array
+ {
+ $workspaceId = $workspace?->id;
+ $channelId = $this->resolveChannelId($channel, $workspace);
+
+ $resolved = ConfigResolved::forScope($workspaceId, $channelId);
+
+ $values = [];
+ foreach ($resolved as $row) {
+ $values[$row->key_code] = $row->getTypedValue();
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get all config values for a category.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @return array
+ */
+ public function category(
+ string $category,
+ ?object $workspace = null,
+ string|Channel|null $channel = null,
+ ): array {
+ $workspaceId = $workspace?->id;
+ $channelId = $this->resolveChannelId($channel, $workspace);
+
+ // Escape LIKE wildcards to prevent unintended pattern matching
+ $escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category);
+
+ $resolved = ConfigResolved::where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->where('key_code', 'LIKE', "{$escapedCategory}.%")
+ ->get();
+
+ $values = [];
+ foreach ($resolved as $row) {
+ $values[$row->key_code] = $row->getTypedValue();
+ }
+
+ return $values;
+ }
+
+ /**
+ * Prime the resolved table for a workspace.
+ *
+ * This is THE computation - runs full resolution and stores results.
+ * Call after workspace creation, config changes, or on schedule.
+ *
+ * Populates both hash (process-scoped) and database (persistent).
+ *
+ * ## When to Call Prime
+ *
+ * - After creating a new workspace
+ * - After bulk config changes (migrations, imports)
+ * - From a scheduled job (`config:prime` command)
+ * - After significant profile hierarchy changes
+ *
+ * ## What Prime Does
+ *
+ * 1. Clears existing resolved values (hash + DB) for the scope
+ * 2. Runs full resolution for all config keys
+ * 3. Stores results in both hash and database
+ * 4. Marks hash as "loaded" to prevent re-loading
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ public function prime(?object $workspace = null, string|Channel|null $channel = null): void
+ {
+ $workspaceId = $workspace?->id;
+ $channelId = $this->resolveChannelId($channel, $workspace);
+
+ // Clear existing resolved values (hash + DB)
+ ConfigResolver::clearAll();
+ ConfigResolved::clearScope($workspaceId, $channelId);
+
+ // Run full resolution
+ $results = $this->resolver->resolveAll($workspace, $channel);
+
+ // Store all resolved values (hash + DB)
+ foreach ($results as $code => $result) {
+ // Store in hash (process-scoped)
+ ConfigResolver::set($code, $result->value);
+
+ // Store in database (persistent)
+ ConfigResolved::store(
+ keyCode: $code,
+ value: $result->value,
+ type: $result->type,
+ workspaceId: $workspaceId,
+ channelId: $channelId,
+ locked: $result->locked,
+ sourceProfileId: $result->profileId,
+ sourceChannelId: $result->channelId,
+ virtual: $result->virtual,
+ );
+ }
+
+ // Mark hash as loaded
+ ConfigResolver::markLoaded();
+ }
+
+ /**
+ * Prime a single key across all affected scopes.
+ *
+ * Clears and re-computes a specific key in both hash and database.
+ */
+ public function primeKey(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): void
+ {
+ // Clear from hash (pattern match)
+ ConfigResolver::clear($keyCode);
+
+ // Clear from database
+ ConfigResolved::where('key_code', $keyCode)
+ ->when($workspaceId !== null, fn ($q) => $q->where('workspace_id', $workspaceId))
+ ->when($channelId !== null, fn ($q) => $q->where('channel_id', $channelId))
+ ->delete();
+
+ // Re-compute this key for the affected scope
+ $workspace = null;
+ if ($workspaceId !== null && class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $workspace = \Core\Tenant\Models\Workspace::find($workspaceId);
+ }
+ $channel = $channelId ? Channel::find($channelId) : null;
+
+ $result = $this->resolver->resolve($keyCode, $workspace, $channel);
+
+ // Store in hash (process-scoped)
+ ConfigResolver::set($keyCode, $result->value);
+
+ // Store in database (persistent)
+ ConfigResolved::store(
+ keyCode: $keyCode,
+ value: $result->value,
+ type: $result->type,
+ workspaceId: $workspaceId,
+ channelId: $channelId,
+ locked: $result->locked,
+ sourceProfileId: $result->profileId,
+ sourceChannelId: $result->channelId,
+ virtual: $result->virtual,
+ );
+ }
+
+ /**
+ * Prime cache for all workspaces.
+ *
+ * Run this from a scheduled command or queue job.
+ * Requires Core\Tenant module to prime workspace-level config.
+ */
+ public function primeAll(): void
+ {
+ // Prime system config
+ $this->prime(null);
+
+ // Prime each workspace (requires Tenant module)
+ if (class_exists(\Core\Tenant\Models\Workspace::class)) {
+ \Core\Tenant\Models\Workspace::chunk(100, function ($workspaces) {
+ foreach ($workspaces as $workspace) {
+ $this->prime($workspace);
+ }
+ });
+ }
+ }
+
+ /**
+ * Invalidate (clear) resolved config for a workspace.
+ *
+ * Clears both hash and database. Next read will lazy-prime.
+ * Fires ConfigInvalidated event.
+ *
+ * ## Cache Invalidation Behaviour
+ *
+ * This method performs a "soft" invalidation:
+ * - Clears the in-memory hash (immediate effect)
+ * - Clears the database resolved table (persistent effect)
+ * - Does NOT re-compute values immediately
+ * - Values are lazy-loaded on next read (lazy-prime)
+ *
+ * Use `prime()` instead if you need immediate re-computation.
+ *
+ * ## Listening for Invalidation
+ *
+ * ```php
+ * use Core\Config\Events\ConfigInvalidated;
+ *
+ * public function handle(ConfigInvalidated $event): void
+ * {
+ * if ($event->isFull()) {
+ * // Full invalidation - clear all module caches
+ * } elseif ($event->affectsKey('mymodule.setting')) {
+ * // Specific key was invalidated
+ * }
+ * }
+ * ```
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ */
+ public function invalidateWorkspace(?object $workspace = null): void
+ {
+ $workspaceId = $workspace?->id;
+
+ // Clear hash (process-scoped)
+ ConfigResolver::clearAll();
+
+ // Clear database (persistent)
+ ConfigResolved::clearWorkspace($workspaceId);
+
+ ConfigInvalidated::dispatch(null, $workspaceId, null);
+ }
+
+ /**
+ * Invalidate (clear) resolved config for a key.
+ *
+ * Clears both hash and database. Next read will lazy-prime.
+ * Fires ConfigInvalidated event.
+ */
+ public function invalidateKey(string $key): void
+ {
+ // Clear hash (process-scoped)
+ ConfigResolver::clear($key);
+
+ // Clear database (persistent)
+ ConfigResolved::clearKey($key);
+
+ ConfigInvalidated::dispatch($key, null, null);
+ }
+
+ /**
+ * Resolve channel to ID.
+ */
+ protected function resolveChannelId(string|Channel|null $channel, ?Workspace $workspace): ?int
+ {
+ if ($channel === null) {
+ return null;
+ }
+
+ if ($channel instanceof Channel) {
+ return $channel->id;
+ }
+
+ $channelModel = Channel::byCode($channel, $workspace?->id);
+
+ return $channelModel?->id;
+ }
+
+ /**
+ * Ensure a config key exists (for dynamic registration).
+ */
+ public function ensureKey(
+ string $code,
+ ConfigType $type,
+ string $category,
+ ?string $description = null,
+ mixed $default = null,
+ ): ConfigKey {
+ return ConfigKey::firstOrCreate(
+ ['code' => $code],
+ [
+ 'type' => $type,
+ 'category' => $category,
+ 'description' => $description,
+ 'default_value' => $default,
+ ]
+ );
+ }
+
+ /**
+ * Register a config key if it doesn't exist.
+ *
+ * Convenience method for Boot.php files.
+ * Note: This persists to database. For runtime-only values, use provide().
+ */
+ public function register(
+ string $code,
+ string $type,
+ string $category,
+ ?string $description = null,
+ mixed $default = null,
+ ): void {
+ $this->ensureKey($code, ConfigType::from($type), $category, $description, $default);
+ }
+
+ /**
+ * Provide a runtime value.
+ *
+ * Modules call this to share settings with other code in the process.
+ * Process-scoped, not persisted to database. Dies with the request.
+ *
+ * Usage in Boot.php:
+ * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
+ * $config->provide('mymodule.timeout', 30, 'int');
+ *
+ * @param string $code Key code (e.g., 'mymodule.api_key')
+ * @param mixed $value The value
+ * @param string|ConfigType $type Value type for casting (currently unused, value stored as-is)
+ */
+ public function provide(string $code, mixed $value, string|ConfigType $type = 'string'): void
+ {
+ // Runtime values just go in the hash (system scope)
+ ConfigResolver::set($code, $value);
+ }
+
+ /**
+ * Check if a runtime value has been provided.
+ */
+ public function hasProvided(string $code): bool
+ {
+ return ConfigResolver::has($code);
+ }
+
+ /**
+ * Validate that a value matches the expected config type.
+ *
+ * @throws \InvalidArgumentException If value type is invalid
+ */
+ protected function validateValueType(mixed $value, ConfigType $type, string $keyCode): void
+ {
+ // Null is allowed for any type (represents unset)
+ if ($value === null) {
+ return;
+ }
+
+ $valid = match ($type) {
+ ConfigType::STRING => is_string($value) || is_numeric($value),
+ ConfigType::BOOL => is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true),
+ ConfigType::INT => is_int($value) || (is_string($value) && ctype_digit(ltrim($value, '-'))),
+ ConfigType::FLOAT => is_float($value) || is_int($value) || is_numeric($value),
+ ConfigType::ARRAY, ConfigType::JSON => is_array($value),
+ };
+
+ if (! $valid) {
+ $actualType = get_debug_type($value);
+ throw new \InvalidArgumentException(
+ "Invalid value type for config key '{$keyCode}': expected {$type->value}, got {$actualType}"
+ );
+ }
+ }
+}
diff --git a/src/Core/Config/ConfigVersioning.php b/src/Core/Config/ConfigVersioning.php
new file mode 100644
index 0000000..325b9ee
--- /dev/null
+++ b/src/Core/Config/ConfigVersioning.php
@@ -0,0 +1,355 @@
+createVersion($workspace, 'Before CDN migration');
+ *
+ * // Make changes...
+ * $config->set('cdn.provider', 'bunny', $profile);
+ *
+ * // Rollback if needed
+ * $versioning->rollback($version->id, $workspace);
+ *
+ * // Compare versions
+ * $diff = $versioning->compare($workspace, $oldVersionId, $newVersionId);
+ * ```
+ *
+ * ## Version Structure
+ *
+ * Each version stores:
+ * - Scope (workspace/system)
+ * - Timestamp
+ * - Label/description
+ * - Full snapshot of all config values
+ * - Author (if available)
+ *
+ * @see ConfigService For runtime config access
+ * @see ConfigExporter For import/export operations
+ */
+class ConfigVersioning
+{
+ /**
+ * Maximum versions to keep per scope (configurable).
+ */
+ protected int $maxVersions;
+
+ public function __construct(
+ protected ConfigService $config,
+ protected ConfigExporter $exporter,
+ ) {
+ $this->maxVersions = (int) config('core.config.max_versions', 50);
+ }
+
+ /**
+ * Create a new config version (snapshot).
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param string $label Version label/description
+ * @param string|null $author Author identifier (user ID, email, etc.)
+ * @return ConfigVersion The created version
+ */
+ public function createVersion(
+ ?object $workspace = null,
+ string $label = '',
+ ?string $author = null,
+ ): ConfigVersion {
+ $profile = $this->getOrCreateProfile($workspace);
+
+ // Get current config as JSON snapshot
+ $snapshot = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false);
+
+ $version = ConfigVersion::create([
+ 'profile_id' => $profile->id,
+ 'workspace_id' => $workspace?->id,
+ 'label' => $label ?: 'Version '.now()->format('Y-m-d H:i:s'),
+ 'snapshot' => $snapshot,
+ 'author' => $author ?? $this->getCurrentAuthor(),
+ 'created_at' => now(),
+ ]);
+
+ // Enforce retention policy
+ $this->pruneOldVersions($profile->id);
+
+ return $version;
+ }
+
+ /**
+ * Rollback to a specific version.
+ *
+ * @param int $versionId Version ID to rollback to
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param bool $createBackup Create a backup version before rollback (default: true)
+ * @return ImportResult Import result with stats
+ *
+ * @throws \InvalidArgumentException If version not found or scope mismatch
+ */
+ public function rollback(
+ int $versionId,
+ ?object $workspace = null,
+ bool $createBackup = true,
+ ): ImportResult {
+ $version = ConfigVersion::find($versionId);
+
+ if ($version === null) {
+ throw new \InvalidArgumentException("Version not found: {$versionId}");
+ }
+
+ // Verify scope matches
+ $workspaceId = $workspace?->id;
+ if ($version->workspace_id !== $workspaceId) {
+ throw new \InvalidArgumentException('Version scope does not match target scope');
+ }
+
+ // Create backup before rollback
+ if ($createBackup) {
+ $this->createVersion($workspace, 'Backup before rollback to version '.$versionId);
+ }
+
+ // Import the snapshot
+ return $this->exporter->importJson($version->snapshot, $workspace);
+ }
+
+ /**
+ * Get all versions for a scope.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param int $limit Maximum versions to return
+ * @return Collection
+ */
+ public function getVersions(?object $workspace = null, int $limit = 20): Collection
+ {
+ $workspaceId = $workspace?->id;
+
+ return ConfigVersion::where('workspace_id', $workspaceId)
+ ->orderByDesc('created_at')
+ ->limit($limit)
+ ->get();
+ }
+
+ /**
+ * Get a specific version.
+ *
+ * @param int $versionId Version ID
+ */
+ public function getVersion(int $versionId): ?ConfigVersion
+ {
+ return ConfigVersion::find($versionId);
+ }
+
+ /**
+ * Compare two versions.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param int $oldVersionId Older version ID
+ * @param int $newVersionId Newer version ID
+ * @return VersionDiff Difference between versions
+ *
+ * @throws \InvalidArgumentException If versions not found
+ */
+ public function compare(?object $workspace, int $oldVersionId, int $newVersionId): VersionDiff
+ {
+ $oldVersion = ConfigVersion::find($oldVersionId);
+ $newVersion = ConfigVersion::find($newVersionId);
+
+ if ($oldVersion === null) {
+ throw new \InvalidArgumentException("Old version not found: {$oldVersionId}");
+ }
+
+ if ($newVersion === null) {
+ throw new \InvalidArgumentException("New version not found: {$newVersionId}");
+ }
+
+ // Parse snapshots
+ $oldData = json_decode($oldVersion->snapshot, true)['values'] ?? [];
+ $newData = json_decode($newVersion->snapshot, true)['values'] ?? [];
+
+ return $this->computeDiff($oldData, $newData);
+ }
+
+ /**
+ * Compare current state with a version.
+ *
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param int $versionId Version ID to compare against
+ * @return VersionDiff Difference between version and current state
+ *
+ * @throws \InvalidArgumentException If version not found
+ */
+ public function compareWithCurrent(?object $workspace, int $versionId): VersionDiff
+ {
+ $version = ConfigVersion::find($versionId);
+
+ if ($version === null) {
+ throw new \InvalidArgumentException("Version not found: {$versionId}");
+ }
+
+ // Get current state
+ $currentJson = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false);
+ $currentData = json_decode($currentJson, true)['values'] ?? [];
+
+ // Get version state
+ $versionData = json_decode($version->snapshot, true)['values'] ?? [];
+
+ return $this->computeDiff($versionData, $currentData);
+ }
+
+ /**
+ * Compute difference between two value arrays.
+ *
+ * @param array $oldValues
+ * @param array $newValues
+ */
+ protected function computeDiff(array $oldValues, array $newValues): VersionDiff
+ {
+ $diff = new VersionDiff;
+
+ // Index by key
+ $oldByKey = collect($oldValues)->keyBy('key');
+ $newByKey = collect($newValues)->keyBy('key');
+
+ // Find added keys (in new but not in old)
+ foreach ($newByKey as $key => $newValue) {
+ if (! $oldByKey->has($key)) {
+ $diff->addAdded($key, $newValue['value']);
+ }
+ }
+
+ // Find removed keys (in old but not in new)
+ foreach ($oldByKey as $key => $oldValue) {
+ if (! $newByKey->has($key)) {
+ $diff->addRemoved($key, $oldValue['value']);
+ }
+ }
+
+ // Find changed keys (in both but different)
+ foreach ($oldByKey as $key => $oldValue) {
+ if ($newByKey->has($key)) {
+ $newValue = $newByKey[$key];
+ if ($oldValue['value'] !== $newValue['value']) {
+ $diff->addChanged($key, $oldValue['value'], $newValue['value']);
+ }
+ if (($oldValue['locked'] ?? false) !== ($newValue['locked'] ?? false)) {
+ $diff->addLockChanged($key, $oldValue['locked'] ?? false, $newValue['locked'] ?? false);
+ }
+ }
+ }
+
+ return $diff;
+ }
+
+ /**
+ * Delete a version.
+ *
+ * @param int $versionId Version ID
+ *
+ * @throws \InvalidArgumentException If version not found
+ */
+ public function deleteVersion(int $versionId): void
+ {
+ $version = ConfigVersion::find($versionId);
+
+ if ($version === null) {
+ throw new \InvalidArgumentException("Version not found: {$versionId}");
+ }
+
+ $version->delete();
+ }
+
+ /**
+ * Prune old versions beyond retention limit.
+ *
+ * @param int $profileId Profile ID
+ */
+ protected function pruneOldVersions(int $profileId): void
+ {
+ $versions = ConfigVersion::where('profile_id', $profileId)
+ ->orderByDesc('created_at')
+ ->get();
+
+ if ($versions->count() > $this->maxVersions) {
+ $toDelete = $versions->slice($this->maxVersions);
+ foreach ($toDelete as $version) {
+ $version->delete();
+ }
+ }
+ }
+
+ /**
+ * Get or create profile for a workspace (or system).
+ */
+ protected function getOrCreateProfile(?object $workspace): ConfigProfile
+ {
+ if ($workspace !== null) {
+ return ConfigProfile::ensureWorkspace($workspace->id);
+ }
+
+ return ConfigProfile::ensureSystem();
+ }
+
+ /**
+ * Get current author for version attribution.
+ */
+ protected function getCurrentAuthor(): ?string
+ {
+ // Try to get authenticated user
+ if (function_exists('auth') && auth()->check()) {
+ $user = auth()->user();
+
+ return $user->email ?? $user->name ?? (string) $user->id;
+ }
+
+ // Return null if no user context
+ return null;
+ }
+
+ /**
+ * Set maximum versions to keep per scope.
+ *
+ * @param int $max Maximum versions
+ */
+ public function setMaxVersions(int $max): void
+ {
+ $this->maxVersions = max(1, $max);
+ }
+
+ /**
+ * Get maximum versions to keep per scope.
+ */
+ public function getMaxVersions(): int
+ {
+ return $this->maxVersions;
+ }
+}
diff --git a/src/Core/Config/Console/ConfigExportCommand.php b/src/Core/Config/Console/ConfigExportCommand.php
new file mode 100644
index 0000000..8264886
--- /dev/null
+++ b/src/Core/Config/Console/ConfigExportCommand.php
@@ -0,0 +1,111 @@
+argument('file');
+ $workspaceSlug = $this->option('workspace');
+ $category = $this->option('category');
+ $includeSensitive = $this->option('include-sensitive');
+ $includeKeys = ! $this->option('no-keys');
+
+ // Resolve workspace
+ $workspace = null;
+ if ($workspaceSlug) {
+ if (! class_exists(\Core\Tenant\Models\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();
+
+ if (! $workspace) {
+ $this->components->error("Workspace not found: {$workspaceSlug}");
+
+ return self::FAILURE;
+ }
+ }
+
+ // Warn about sensitive data
+ if ($includeSensitive) {
+ $this->components->warn('WARNING: Export will include sensitive values. Handle the file securely!');
+
+ if (! $this->confirm('Are you sure you want to include sensitive values?')) {
+ $this->components->info('Export cancelled.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ // Determine format from extension
+ $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+ $format = match ($extension) {
+ 'yaml', 'yml' => 'YAML',
+ default => 'JSON',
+ };
+
+ $this->components->task("Exporting {$format} config", function () use ($exporter, $file, $workspace, $includeSensitive, $includeKeys, $category) {
+ $content = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) {
+ 'yaml', 'yml' => $exporter->exportYaml($workspace, $includeSensitive, $includeKeys, $category),
+ default => $exporter->exportJson($workspace, $includeSensitive, $includeKeys, $category),
+ };
+
+ file_put_contents($file, $content);
+ });
+
+ $scope = $workspace ? "workspace: {$workspace->slug}" : 'system';
+ $this->components->info("Config exported to {$file} ({$scope})");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get autocompletion suggestions.
+ */
+ public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\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 ($input->mustSuggestOptionValuesFor('category')) {
+ $suggestions->suggestValues(\Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray());
+ }
+ }
+}
diff --git a/src/Core/Config/Console/ConfigImportCommand.php b/src/Core/Config/Console/ConfigImportCommand.php
new file mode 100644
index 0000000..0788948
--- /dev/null
+++ b/src/Core/Config/Console/ConfigImportCommand.php
@@ -0,0 +1,185 @@
+argument('file');
+ $workspaceSlug = $this->option('workspace');
+ $dryRun = $this->option('dry-run');
+ $skipBackup = $this->option('no-backup');
+ $force = $this->option('force');
+
+ // Check file exists
+ if (! file_exists($file)) {
+ $this->components->error("File not found: {$file}");
+
+ return self::FAILURE;
+ }
+
+ // Resolve workspace
+ $workspace = null;
+ if ($workspaceSlug) {
+ if (! class_exists(\Core\Tenant\Models\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();
+
+ if (! $workspace) {
+ $this->components->error("Workspace not found: {$workspaceSlug}");
+
+ return self::FAILURE;
+ }
+ }
+
+ // Read file content
+ $content = file_get_contents($file);
+ if ($content === false) {
+ $this->components->error("Failed to read file: {$file}");
+
+ return self::FAILURE;
+ }
+
+ // Determine format from extension
+ $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+ $format = match ($extension) {
+ 'yaml', 'yml' => 'YAML',
+ default => 'JSON',
+ };
+
+ $scope = $workspace ? "workspace: {$workspace->slug}" : 'system';
+
+ if ($dryRun) {
+ $this->components->info("Dry-run import from {$file} ({$scope}):");
+ } else {
+ if (! $force) {
+ $this->components->warn("This will import config from {$file} to {$scope}.");
+
+ if (! $this->confirm('Are you sure you want to continue?')) {
+ $this->components->info('Import cancelled.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ // Create backup before import
+ if (! $skipBackup && ! $dryRun) {
+ $this->components->task('Creating backup version', function () use ($versioning, $workspace, $file) {
+ $versioning->createVersion(
+ $workspace,
+ 'Backup before import from '.basename($file)
+ );
+ });
+ }
+ }
+
+ // Perform import
+ $result = null;
+ $this->components->task("Importing {$format} config", function () use ($exporter, $content, $extension, $workspace, $dryRun, &$result) {
+ $result = match ($extension) {
+ 'yaml', 'yml' => $exporter->importYaml($content, $workspace, $dryRun),
+ default => $exporter->importJson($content, $workspace, $dryRun),
+ };
+ });
+
+ // Show results
+ $this->newLine();
+
+ if ($dryRun) {
+ $this->components->info('Dry-run results (no changes applied):');
+ }
+
+ // Display created items
+ if ($result->createdCount() > 0) {
+ $this->components->twoColumnDetail('Created>', $result->createdCount().' items');
+ foreach ($result->getCreated() as $item) {
+ $this->components->bulletList(["{$item['type']}: {$item['code']}"]);
+ }
+ }
+
+ // Display updated items
+ if ($result->updatedCount() > 0) {
+ $this->components->twoColumnDetail('Updated>', $result->updatedCount().' items');
+ foreach ($result->getUpdated() as $item) {
+ $this->components->bulletList(["{$item['type']}: {$item['code']}"]);
+ }
+ }
+
+ // Display skipped items
+ if ($result->skippedCount() > 0) {
+ $this->components->twoColumnDetail('Skipped>', $result->skippedCount().' items');
+ foreach ($result->getSkipped() as $reason) {
+ $this->components->bulletList([$reason]);
+ }
+ }
+
+ // Display errors
+ if ($result->hasErrors()) {
+ $this->newLine();
+ $this->components->error('Errors:');
+ foreach ($result->getErrors() as $error) {
+ $this->components->bulletList(["{$error}>"]);
+ }
+
+ return self::FAILURE;
+ }
+
+ $this->newLine();
+
+ if ($dryRun) {
+ $this->components->info("Dry-run complete: {$result->getSummary()}");
+ } else {
+ $this->components->info("Import complete: {$result->getSummary()}");
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get autocompletion suggestions.
+ */
+ public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestOptionValuesFor('workspace')) {
+ if (class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
+ }
+ }
+ }
+}
diff --git a/src/Core/Config/Console/ConfigListCommand.php b/src/Core/Config/Console/ConfigListCommand.php
new file mode 100644
index 0000000..97e3e9a
--- /dev/null
+++ b/src/Core/Config/Console/ConfigListCommand.php
@@ -0,0 +1,106 @@
+option('workspace');
+ $category = $this->option('category');
+ $configuredOnly = $this->option('configured');
+
+ $workspace = null;
+
+ if ($workspaceSlug) {
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $this->error('Tenant module not installed. Cannot filter by workspace.');
+
+ return self::FAILURE;
+ }
+
+ $workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
+
+ if (! $workspace) {
+ $this->error("Workspace not found: {$workspaceSlug}");
+
+ return self::FAILURE;
+ }
+
+ $this->info("Config for workspace: {$workspace->slug}");
+ } else {
+ $this->info('System config:');
+ }
+
+ $this->newLine();
+
+ $query = ConfigKey::query();
+
+ if ($category) {
+ $query->where('category', $category);
+ }
+
+ $keys = $query->orderBy('category')->orderBy('code')->get();
+
+ $rows = [];
+
+ foreach ($keys as $key) {
+ $result = $config->resolve($key->code, $workspace);
+
+ if ($configuredOnly && ! $result->isConfigured()) {
+ continue;
+ }
+
+ $value = $result->get();
+ $displayValue = match (true) {
+ is_null($value) => 'null>',
+ is_bool($value) => $value ? 'true>' : 'false>',
+ is_array($value) => '[array]>',
+ is_string($value) && strlen($value) > 40 => substr($value, 0, 37).'...',
+ default => (string) $value,
+ };
+
+ $rows[] = [
+ $key->code,
+ $key->category,
+ $key->type->value,
+ $displayValue,
+ $result->isLocked() ? 'LOCKED>' : '',
+ $result->resolvedFrom?->value ?? 'default>',
+ ];
+ }
+
+ if (empty($rows)) {
+ $this->warn('No config keys found.');
+
+ return self::SUCCESS;
+ }
+
+ $this->table(
+ ['Key', 'Category', 'Type', 'Value', 'Status', 'Source'],
+ $rows
+ );
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Core/Config/Console/ConfigPrimeCommand.php b/src/Core/Config/Console/ConfigPrimeCommand.php
new file mode 100644
index 0000000..c121eca
--- /dev/null
+++ b/src/Core/Config/Console/ConfigPrimeCommand.php
@@ -0,0 +1,79 @@
+argument('workspace');
+ $systemOnly = $this->option('system');
+
+ if ($systemOnly) {
+ $this->info('Priming system config cache...');
+ $config->prime(null);
+ $this->info('System config cached.');
+
+ return self::SUCCESS;
+ }
+
+ if ($workspaceSlug) {
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $this->error('Tenant module not installed. Cannot prime workspace config.');
+
+ return self::FAILURE;
+ }
+
+ $workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
+
+ if (! $workspace) {
+ $this->error("Workspace not found: {$workspaceSlug}");
+
+ return self::FAILURE;
+ }
+
+ $this->info("Priming config cache for workspace: {$workspace->slug}");
+ $config->prime($workspace);
+ $this->info('Workspace config cached.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info('Priming config cache for all workspaces...');
+
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ $this->warn('Tenant module not installed. Only priming system config.');
+ $config->prime(null);
+ $this->info('System config cached.');
+
+ return self::SUCCESS;
+ }
+
+ $this->withProgressBar(\Core\Tenant\Models\Workspace::all(), function ($workspace) use ($config) {
+ $config->prime($workspace);
+ });
+
+ $this->newLine();
+ $this->info('All config caches primed.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Core/Config/Console/ConfigVersionCommand.php b/src/Core/Config/Console/ConfigVersionCommand.php
new file mode 100644
index 0000000..fa4140a
--- /dev/null
+++ b/src/Core/Config/Console/ConfigVersionCommand.php
@@ -0,0 +1,417 @@
+argument('action');
+ $arg1 = $this->argument('arg1');
+ $arg2 = $this->argument('arg2');
+ $workspaceSlug = $this->option('workspace');
+
+ // Resolve workspace
+ $workspace = null;
+ if ($workspaceSlug) {
+ if (! class_exists(\Core\Tenant\Models\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();
+
+ if (! $workspace) {
+ $this->components->error("Workspace not found: {$workspaceSlug}");
+
+ return self::FAILURE;
+ }
+ }
+
+ return match ($action) {
+ 'list' => $this->listVersions($versioning, $workspace),
+ 'create' => $this->createVersion($versioning, $workspace, $arg1),
+ 'show' => $this->showVersion($versioning, $arg1),
+ 'rollback' => $this->rollbackVersion($versioning, $workspace, $arg1),
+ 'compare' => $this->compareVersions($versioning, $workspace, $arg1, $arg2),
+ 'diff' => $this->diffWithCurrent($versioning, $workspace, $arg1),
+ 'delete' => $this->deleteVersion($versioning, $arg1),
+ default => $this->invalidAction($action),
+ };
+ }
+
+ /**
+ * List versions.
+ */
+ protected function listVersions(ConfigVersioning $versioning, ?object $workspace): int
+ {
+ $limit = (int) $this->option('limit');
+ $versions = $versioning->getVersions($workspace, $limit);
+
+ $scope = $workspace ? "workspace: {$workspace->slug}" : 'system';
+ $this->components->info("Config versions for {$scope}:");
+
+ if ($versions->isEmpty()) {
+ $this->components->warn('No versions found.');
+
+ return self::SUCCESS;
+ }
+
+ $rows = $versions->map(fn (ConfigVersion $v) => [
+ $v->id,
+ $v->label,
+ $v->author ?? '->',
+ $v->created_at->format('Y-m-d H:i:s'),
+ $v->created_at->diffForHumans(),
+ ])->toArray();
+
+ $this->table(
+ ['ID', 'Label', 'Author', 'Created', 'Age'],
+ $rows
+ );
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Create a new version.
+ */
+ protected function createVersion(ConfigVersioning $versioning, ?object $workspace, ?string $label): int
+ {
+ $label = $label ?? 'Manual snapshot';
+
+ $version = null;
+ $this->components->task("Creating version: {$label}", function () use ($versioning, $workspace, $label, &$version) {
+ $version = $versioning->createVersion($workspace, $label);
+ });
+
+ $this->components->info("Version created: ID {$version->id}");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Show version details.
+ */
+ protected function showVersion(ConfigVersioning $versioning, ?string $versionId): int
+ {
+ if ($versionId === null) {
+ $this->components->error('Version ID required.');
+
+ return self::FAILURE;
+ }
+
+ $version = $versioning->getVersion((int) $versionId);
+
+ if ($version === null) {
+ $this->components->error("Version not found: {$versionId}");
+
+ return self::FAILURE;
+ }
+
+ $this->components->info("Version #{$version->id}: {$version->label}");
+ $this->components->twoColumnDetail('Created', $version->created_at->format('Y-m-d H:i:s'));
+ $this->components->twoColumnDetail('Author', $version->author ?? '-');
+ $this->components->twoColumnDetail('Workspace ID', $version->workspace_id ?? 'system');
+
+ $values = $version->getValues();
+ $this->newLine();
+ $this->components->info('Values ('.count($values).' items):');
+
+ $rows = array_map(function ($v) {
+ $displayValue = match (true) {
+ is_array($v['value']) => '[array]>',
+ is_null($v['value']) => 'null>',
+ is_bool($v['value']) => $v['value'] ? 'true>' : 'false>',
+ is_string($v['value']) && strlen($v['value']) > 40 => substr($v['value'], 0, 37).'...',
+ default => (string) $v['value'],
+ };
+
+ return [
+ $v['key'],
+ $displayValue,
+ $v['locked'] ?? false ? 'LOCKED>' : '',
+ ];
+ }, $values);
+
+ $this->table(['Key', 'Value', 'Status'], $rows);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Rollback to a version.
+ */
+ protected function rollbackVersion(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int
+ {
+ if ($versionId === null) {
+ $this->components->error('Version ID required.');
+
+ return self::FAILURE;
+ }
+
+ $version = $versioning->getVersion((int) $versionId);
+
+ if ($version === null) {
+ $this->components->error("Version not found: {$versionId}");
+
+ return self::FAILURE;
+ }
+
+ $scope = $workspace ? "workspace: {$workspace->slug}" : 'system';
+
+ if (! $this->option('force')) {
+ $this->components->warn("This will restore config to version #{$version->id}: {$version->label}");
+ $this->components->warn("Scope: {$scope}");
+
+ if (! $this->confirm('Are you sure you want to rollback?')) {
+ $this->components->info('Rollback cancelled.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ $createBackup = ! $this->option('no-backup');
+ $result = null;
+
+ $this->components->task('Rolling back config', function () use ($versioning, $workspace, $versionId, $createBackup, &$result) {
+ $result = $versioning->rollback((int) $versionId, $workspace, $createBackup);
+ });
+
+ $this->newLine();
+ $this->components->info("Rollback complete: {$result->getSummary()}");
+
+ if ($createBackup) {
+ $this->components->info('A backup version was created before rollback.');
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Compare two versions.
+ */
+ protected function compareVersions(ConfigVersioning $versioning, ?object $workspace, ?string $oldId, ?string $newId): int
+ {
+ if ($oldId === null || $newId === null) {
+ $this->components->error('Two version IDs required for comparison.');
+
+ return self::FAILURE;
+ }
+
+ $diff = $versioning->compare($workspace, (int) $oldId, (int) $newId);
+
+ $this->components->info("Comparing version #{$oldId} to #{$newId}:");
+ $this->newLine();
+
+ if ($diff->isEmpty()) {
+ $this->components->info('No differences found.');
+
+ return self::SUCCESS;
+ }
+
+ $this->displayDiff($diff);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Compare version with current state.
+ */
+ protected function diffWithCurrent(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int
+ {
+ if ($versionId === null) {
+ $this->components->error('Version ID required.');
+
+ return self::FAILURE;
+ }
+
+ $diff = $versioning->compareWithCurrent($workspace, (int) $versionId);
+
+ $this->components->info("Comparing version #{$versionId} to current state:");
+ $this->newLine();
+
+ if ($diff->isEmpty()) {
+ $this->components->info('No differences found. Current state matches the version.');
+
+ return self::SUCCESS;
+ }
+
+ $this->displayDiff($diff);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Display a diff.
+ */
+ protected function displayDiff(\Core\Config\VersionDiff $diff): void
+ {
+ $this->components->info("Summary: {$diff->getSummary()}");
+ $this->newLine();
+
+ // Added
+ if (count($diff->getAdded()) > 0) {
+ $this->components->twoColumnDetail('Added>', count($diff->getAdded()).' keys');
+ foreach ($diff->getAdded() as $item) {
+ $this->line(" +> {$item['key']}");
+ }
+ $this->newLine();
+ }
+
+ // Removed
+ if (count($diff->getRemoved()) > 0) {
+ $this->components->twoColumnDetail('Removed>', count($diff->getRemoved()).' keys');
+ foreach ($diff->getRemoved() as $item) {
+ $this->line(" -> {$item['key']}");
+ }
+ $this->newLine();
+ }
+
+ // Changed
+ if (count($diff->getChanged()) > 0) {
+ $this->components->twoColumnDetail('Changed>', count($diff->getChanged()).' keys');
+ foreach ($diff->getChanged() as $item) {
+ $oldDisplay = $this->formatValue($item['old']);
+ $newDisplay = $this->formatValue($item['new']);
+ $this->line(" ~> {$item['key']}");
+ $this->line(" old:> {$oldDisplay}");
+ $this->line(" new:> {$newDisplay}");
+ }
+ $this->newLine();
+ }
+
+ // Lock changes
+ if (count($diff->getLockChanged()) > 0) {
+ $this->components->twoColumnDetail('Lock Changed>', count($diff->getLockChanged()).' keys');
+ foreach ($diff->getLockChanged() as $item) {
+ $oldLock = $item['old'] ? 'LOCKED' : 'unlocked';
+ $newLock = $item['new'] ? 'LOCKED' : 'unlocked';
+ $this->line(" *> {$item['key']}: {$oldLock} -> {$newLock}");
+ }
+ }
+ }
+
+ /**
+ * Format a value for display.
+ */
+ protected function formatValue(mixed $value): string
+ {
+ return match (true) {
+ is_array($value) => '[array]',
+ is_null($value) => 'null',
+ is_bool($value) => $value ? 'true' : 'false',
+ is_string($value) && strlen($value) > 50 => '"'.substr($value, 0, 47).'..."',
+ default => (string) $value,
+ };
+ }
+
+ /**
+ * Delete a version.
+ */
+ protected function deleteVersion(ConfigVersioning $versioning, ?string $versionId): int
+ {
+ if ($versionId === null) {
+ $this->components->error('Version ID required.');
+
+ return self::FAILURE;
+ }
+
+ $version = $versioning->getVersion((int) $versionId);
+
+ if ($version === null) {
+ $this->components->error("Version not found: {$versionId}");
+
+ return self::FAILURE;
+ }
+
+ if (! $this->option('force')) {
+ $this->components->warn("This will permanently delete version #{$version->id}: {$version->label}");
+
+ if (! $this->confirm('Are you sure you want to delete this version?')) {
+ $this->components->info('Delete cancelled.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ $versioning->deleteVersion((int) $versionId);
+ $this->components->info("Version #{$versionId} deleted.");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Handle invalid action.
+ */
+ protected function invalidAction(string $action): int
+ {
+ $this->components->error("Invalid action: {$action}");
+ $this->newLine();
+ $this->components->info('Available actions:');
+ $this->components->bulletList([
+ 'list - List all versions',
+ 'create - Create a new version snapshot',
+ 'show - Show version details',
+ 'rollback - Restore config to a version',
+ 'compare - Compare two versions',
+ 'diff - Compare version with current state',
+ 'delete - Delete a version',
+ ]);
+
+ return self::FAILURE;
+ }
+
+ /**
+ * Get autocompletion suggestions.
+ */
+ public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\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());
+ }
+ }
+ }
+}
diff --git a/src/Core/Config/Contracts/ConfigProvider.php b/src/Core/Config/Contracts/ConfigProvider.php
new file mode 100644
index 0000000..3cdd1a5
--- /dev/null
+++ b/src/Core/Config/Contracts/ConfigProvider.php
@@ -0,0 +1,107 @@
+registerProvider('bio.*', new BioConfigProvider());
+ * ```
+ *
+ * ## Example Implementation
+ *
+ * ```php
+ * class BioConfigProvider implements ConfigProvider
+ * {
+ * public function pattern(): string
+ * {
+ * return 'bio.*';
+ * }
+ *
+ * public function resolve(
+ * string $keyCode,
+ * ?object $workspace,
+ * string|Channel|null $channel
+ * ): mixed {
+ * // Extract the specific key (e.g., "bio.theme" -> "theme")
+ * $subKey = substr($keyCode, 4);
+ *
+ * return match ($subKey) {
+ * 'theme' => $this->getTheme($workspace),
+ * 'layout' => $this->getLayout($workspace),
+ * default => null,
+ * };
+ * }
+ * }
+ * ```
+ *
+ *
+ * @see \Core\Config\ConfigResolver::registerProvider()
+ */
+interface ConfigProvider
+{
+ /**
+ * Get the key pattern this provider handles.
+ *
+ * Supports wildcards:
+ * - `*` matches any characters
+ * - `bio.*` matches "bio.theme", "bio.colors.primary", etc.
+ *
+ * @return string The key pattern (e.g., 'bio.*', 'theme.colors.*')
+ */
+ public function pattern(): string;
+
+ /**
+ * Resolve a config value for the given key.
+ *
+ * Called when a key matches this provider's pattern. Return null if the
+ * provider cannot supply a value for this specific key, allowing other
+ * providers or the database to supply the value.
+ *
+ * @param string $keyCode The full config key being resolved
+ * @param object|null $workspace Workspace model instance or null for system scope
+ * @param string|Channel|null $channel Channel code or object
+ * @return mixed The config value, or null if not provided
+ */
+ public function resolve(
+ string $keyCode,
+ ?object $workspace,
+ string|Channel|null $channel
+ ): mixed;
+}
diff --git a/src/Core/Config/Database/Seeders/ConfigKeySeeder.php b/src/Core/Config/Database/Seeders/ConfigKeySeeder.php
new file mode 100644
index 0000000..d47d3d5
--- /dev/null
+++ b/src/Core/Config/Database/Seeders/ConfigKeySeeder.php
@@ -0,0 +1,87 @@
+ $key[0]],
+ [
+ 'type' => $key[1],
+ 'category' => $key[2],
+ 'description' => $key[3] ?? null,
+ 'default_value' => $key[4] ?? null,
+ ]
+ );
+ }
+ }
+}
diff --git a/src/Core/Config/Enums/ConfigType.php b/src/Core/Config/Enums/ConfigType.php
new file mode 100644
index 0000000..9df22d1
--- /dev/null
+++ b/src/Core/Config/Enums/ConfigType.php
@@ -0,0 +1,60 @@
+ (string) $value,
+ self::BOOL => filter_var($value, FILTER_VALIDATE_BOOLEAN),
+ self::INT => (int) $value,
+ self::FLOAT => (float) $value,
+ self::ARRAY => is_array($value) ? $value : json_decode($value, true) ?? [],
+ self::JSON => is_string($value) ? json_decode($value, true) : $value,
+ };
+ }
+
+ /**
+ * Get default value for this type.
+ */
+ public function default(): mixed
+ {
+ return match ($this) {
+ self::STRING => '',
+ self::BOOL => false,
+ self::INT => 0,
+ self::FLOAT => 0.0,
+ self::ARRAY, self::JSON => [],
+ };
+ }
+}
diff --git a/src/Core/Config/Enums/ScopeType.php b/src/Core/Config/Enums/ScopeType.php
new file mode 100644
index 0000000..cca983d
--- /dev/null
+++ b/src/Core/Config/Enums/ScopeType.php
@@ -0,0 +1,52 @@
+ 0,
+ self::ORG => 10,
+ self::WORKSPACE => 20,
+ };
+ }
+
+ /**
+ * Get all scopes in resolution order (most specific first).
+ *
+ * @return array
+ */
+ public static function resolutionOrder(): array
+ {
+ return [
+ self::WORKSPACE,
+ self::ORG,
+ self::SYSTEM,
+ ];
+ }
+}
diff --git a/src/Core/Config/Events/ConfigChanged.php b/src/Core/Config/Events/ConfigChanged.php
new file mode 100644
index 0000000..5f23244
--- /dev/null
+++ b/src/Core/Config/Events/ConfigChanged.php
@@ -0,0 +1,89 @@
+keyCode === 'cdn.bunny.api_key') {
+ * // API key changed - refresh CDN client
+ * $this->cdnService->refreshClient();
+ * }
+ *
+ * // Check for prefix matches
+ * if (str_starts_with($event->keyCode, 'mymodule.')) {
+ * Cache::tags(['mymodule'])->flush();
+ * }
+ * }
+ * }
+ * ```
+ *
+ * ## In Module Boot.php
+ *
+ * ```php
+ * use Core\Config\Events\ConfigChanged;
+ *
+ * class Boot
+ * {
+ * public static array $listens = [
+ * ConfigChanged::class => 'onConfigChanged',
+ * ];
+ *
+ * public function onConfigChanged(ConfigChanged $event): void
+ * {
+ * // Handle config changes
+ * }
+ * }
+ * ```
+ *
+ * @see ConfigInvalidated For cache invalidation events
+ * @see ConfigLocked For when config values are locked
+ */
+class ConfigChanged
+{
+ use Dispatchable;
+ use InteractsWithSockets;
+ use SerializesModels;
+
+ public function __construct(
+ public readonly string $keyCode,
+ public readonly mixed $value,
+ public readonly mixed $previousValue,
+ public readonly ConfigProfile $profile,
+ public readonly ?int $channelId = null,
+ ) {}
+}
diff --git a/src/Core/Config/Events/ConfigInvalidated.php b/src/Core/Config/Events/ConfigInvalidated.php
new file mode 100644
index 0000000..df8be80
--- /dev/null
+++ b/src/Core/Config/Events/ConfigInvalidated.php
@@ -0,0 +1,96 @@
+affectsKey('mymodule.api_key')) {
+ * // Clear our module's cached API client
+ * Cache::forget('mymodule:api_client');
+ * }
+ *
+ * // Or handle full invalidation
+ * if ($event->isFull()) {
+ * // Clear all module caches
+ * Cache::tags(['mymodule'])->flush();
+ * }
+ * }
+ * }
+ * ```
+ *
+ * ## Invalidation Sources
+ *
+ * This event is fired by:
+ * - `ConfigService::invalidateWorkspace()` - Clears workspace config
+ * - `ConfigService::invalidateKey()` - Clears a specific key
+ *
+ * @see ConfigChanged For changes to specific config values
+ * @see ConfigLocked For when config values are locked
+ */
+class ConfigInvalidated
+{
+ use Dispatchable;
+ use InteractsWithSockets;
+ use SerializesModels;
+
+ public function __construct(
+ public readonly ?string $keyCode = null,
+ public readonly ?int $workspaceId = null,
+ public readonly ?int $channelId = null,
+ ) {}
+
+ /**
+ * Is this a full invalidation?
+ */
+ public function isFull(): bool
+ {
+ return $this->keyCode === null && $this->workspaceId === null;
+ }
+
+ /**
+ * Does this invalidation affect a specific key?
+ */
+ public function affectsKey(string $key): bool
+ {
+ if ($this->keyCode === null) {
+ return true; // Full invalidation affects all keys
+ }
+
+ // Exact match or prefix match
+ return $this->keyCode === $key || str_starts_with($key, $this->keyCode.'.');
+ }
+}
diff --git a/src/Core/Config/Events/ConfigLocked.php b/src/Core/Config/Events/ConfigLocked.php
new file mode 100644
index 0000000..d98f1cb
--- /dev/null
+++ b/src/Core/Config/Events/ConfigLocked.php
@@ -0,0 +1,35 @@
+
+ */
+ protected array $created = [];
+
+ /**
+ * Items updated during import.
+ *
+ * @var array
+ */
+ protected array $updated = [];
+
+ /**
+ * Items skipped during import.
+ *
+ * @var array
+ */
+ protected array $skipped = [];
+
+ /**
+ * Errors encountered during import.
+ *
+ * @var array
+ */
+ protected array $errors = [];
+
+ /**
+ * Add a created item.
+ *
+ * @param string $code The item code/identifier
+ * @param string $type The item type (key, value)
+ */
+ public function addCreated(string $code, string $type): void
+ {
+ $this->created[] = ['code' => $code, 'type' => $type];
+ }
+
+ /**
+ * Add an updated item.
+ *
+ * @param string $code The item code/identifier
+ * @param string $type The item type (key, value)
+ */
+ public function addUpdated(string $code, string $type): void
+ {
+ $this->updated[] = ['code' => $code, 'type' => $type];
+ }
+
+ /**
+ * Add a skipped item.
+ *
+ * @param string $reason Reason for skipping
+ */
+ public function addSkipped(string $reason): void
+ {
+ $this->skipped[] = $reason;
+ }
+
+ /**
+ * Add an error.
+ *
+ * @param string $message Error message
+ */
+ public function addError(string $message): void
+ {
+ $this->errors[] = $message;
+ }
+
+ /**
+ * Get created items.
+ *
+ * @return array
+ */
+ public function getCreated(): array
+ {
+ return $this->created;
+ }
+
+ /**
+ * Get updated items.
+ *
+ * @return array
+ */
+ public function getUpdated(): array
+ {
+ return $this->updated;
+ }
+
+ /**
+ * Get skipped items.
+ *
+ * @return array
+ */
+ public function getSkipped(): array
+ {
+ return $this->skipped;
+ }
+
+ /**
+ * Get errors.
+ *
+ * @return array
+ */
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+
+ /**
+ * Check if import was successful (no errors).
+ */
+ public function isSuccessful(): bool
+ {
+ return empty($this->errors);
+ }
+
+ /**
+ * Check if any changes were made.
+ */
+ public function hasChanges(): bool
+ {
+ return ! empty($this->created) || ! empty($this->updated);
+ }
+
+ /**
+ * Check if there were any errors.
+ */
+ public function hasErrors(): bool
+ {
+ return ! empty($this->errors);
+ }
+
+ /**
+ * Get total count of created items.
+ */
+ public function createdCount(): int
+ {
+ return count($this->created);
+ }
+
+ /**
+ * Get total count of updated items.
+ */
+ public function updatedCount(): int
+ {
+ return count($this->updated);
+ }
+
+ /**
+ * Get total count of skipped items.
+ */
+ public function skippedCount(): int
+ {
+ return count($this->skipped);
+ }
+
+ /**
+ * Get total count of errors.
+ */
+ public function errorCount(): int
+ {
+ return count($this->errors);
+ }
+
+ /**
+ * Get summary string.
+ */
+ public function getSummary(): string
+ {
+ $parts = [];
+
+ if ($this->createdCount() > 0) {
+ $parts[] = "{$this->createdCount()} created";
+ }
+
+ if ($this->updatedCount() > 0) {
+ $parts[] = "{$this->updatedCount()} updated";
+ }
+
+ if ($this->skippedCount() > 0) {
+ $parts[] = "{$this->skippedCount()} skipped";
+ }
+
+ if ($this->errorCount() > 0) {
+ $parts[] = "{$this->errorCount()} errors";
+ }
+
+ if (empty($parts)) {
+ return 'No changes';
+ }
+
+ return implode(', ', $parts);
+ }
+
+ /**
+ * Convert to array for JSON serialization.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'success' => $this->isSuccessful(),
+ 'summary' => $this->getSummary(),
+ 'created' => $this->created,
+ 'updated' => $this->updated,
+ 'skipped' => $this->skipped,
+ 'errors' => $this->errors,
+ 'counts' => [
+ 'created' => $this->createdCount(),
+ 'updated' => $this->updatedCount(),
+ 'skipped' => $this->skippedCount(),
+ 'errors' => $this->errorCount(),
+ ],
+ ];
+ }
+}
diff --git a/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php b/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php
new file mode 100644
index 0000000..624c40c
--- /dev/null
+++ b/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php
@@ -0,0 +1,122 @@
+id();
+ $table->string('code')->unique();
+ $table->foreignId('parent_id')->nullable()
+ ->constrained('config_keys')
+ ->nullOnDelete();
+ $table->string('type')->default('string');
+ $table->string('category')->index();
+ $table->string('description')->nullable();
+ $table->json('default_value')->nullable();
+ $table->timestamps();
+
+ $table->index(['category', 'code']);
+ });
+
+ // 2. Config Profiles (scope containers)
+ Schema::create('config_profiles', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('scope_type')->index();
+ $table->unsignedBigInteger('scope_id')->nullable()->index();
+ $table->foreignId('parent_profile_id')->nullable()
+ ->constrained('config_profiles')
+ ->nullOnDelete();
+ $table->integer('priority')->default(0);
+ $table->timestamps();
+
+ $table->index(['scope_type', 'scope_id']);
+ $table->unique(['scope_type', 'scope_id', 'priority']);
+ });
+
+ // 3. Config Values
+ Schema::create('config_values', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('profile_id')
+ ->constrained('config_profiles')
+ ->cascadeOnDelete();
+ $table->foreignId('key_id')
+ ->constrained('config_keys')
+ ->cascadeOnDelete();
+ $table->json('value')->nullable();
+ $table->boolean('locked')->default(false);
+ $table->foreignId('inherited_from')->nullable()
+ ->constrained('config_profiles')
+ ->nullOnDelete();
+ $table->timestamps();
+
+ $table->unique(['profile_id', 'key_id']);
+ $table->index(['key_id', 'locked']);
+ });
+
+ // 4. Config Channels
+ Schema::create('config_channels', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('code')->unique();
+ $table->string('type')->default('notification');
+ $table->json('settings')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->integer('sort_order')->default(0);
+ $table->timestamps();
+
+ $table->index(['type', 'is_active']);
+ });
+
+ // 5. Config Resolved Cache
+ Schema::create('config_resolved', function (Blueprint $table) {
+ $table->id();
+ $table->string('scope_type');
+ $table->unsignedBigInteger('scope_id');
+ $table->string('key_code');
+ $table->json('resolved_value')->nullable();
+ $table->foreignId('source_profile_id')->nullable()
+ ->constrained('config_profiles')
+ ->nullOnDelete();
+ $table->timestamp('resolved_at');
+ $table->timestamps();
+
+ $table->unique(['scope_type', 'scope_id', 'key_code'], 'config_resolved_unique');
+ $table->index(['scope_type', 'scope_id']);
+ $table->index('key_code');
+ });
+
+ Schema::enableForeignKeyConstraints();
+ }
+
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('config_resolved');
+ Schema::dropIfExists('config_channels');
+ Schema::dropIfExists('config_values');
+ Schema::dropIfExists('config_profiles');
+ Schema::dropIfExists('config_keys');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php b/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php
new file mode 100644
index 0000000..35cb829
--- /dev/null
+++ b/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php
@@ -0,0 +1,36 @@
+softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('config_profiles', function (Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ }
+};
diff --git a/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php b/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php
new file mode 100644
index 0000000..51159bd
--- /dev/null
+++ b/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php
@@ -0,0 +1,39 @@
+boolean('is_sensitive')->default(false)->after('default_value');
+ $table->index('is_sensitive');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('config_keys', function (Blueprint $table) {
+ $table->dropIndex(['is_sensitive']);
+ $table->dropColumn('is_sensitive');
+ });
+ }
+};
diff --git a/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php b/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php
new file mode 100644
index 0000000..b5efe8a
--- /dev/null
+++ b/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php
@@ -0,0 +1,43 @@
+id();
+ $table->foreignId('profile_id')
+ ->constrained('config_profiles')
+ ->cascadeOnDelete();
+ $table->unsignedBigInteger('workspace_id')->nullable()->index();
+ $table->string('label');
+ $table->longText('snapshot'); // JSON snapshot of all config values
+ $table->string('author')->nullable();
+ $table->timestamp('created_at');
+
+ $table->index(['profile_id', 'created_at']);
+ $table->index(['workspace_id', 'created_at']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('config_versions');
+ }
+};
diff --git a/src/Core/Config/Models/Channel.php b/src/Core/Config/Models/Channel.php
new file mode 100644
index 0000000..4ac8996
--- /dev/null
+++ b/src/Core/Config/Models/Channel.php
@@ -0,0 +1,209 @@
+ 'array',
+ ];
+
+ /**
+ * Parent channel (for inheritance).
+ */
+ public function parent(): BelongsTo
+ {
+ return $this->belongsTo(self::class, 'parent_id');
+ }
+
+ /**
+ * Child channels.
+ */
+ public function children(): HasMany
+ {
+ return $this->hasMany(self::class, 'parent_id');
+ }
+
+ /**
+ * Workspace this channel belongs to (null = system channel).
+ *
+ * Requires Core\Tenant module to be installed.
+ */
+ public function workspace(): BelongsTo
+ {
+ if (class_exists(\Core\Tenant\Models\Workspace::class)) {
+ return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
+ }
+
+ // Return a null relationship when Tenant module is not installed
+ return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0');
+ }
+
+ /**
+ * Config values for this channel.
+ */
+ public function values(): HasMany
+ {
+ return $this->hasMany(ConfigValue::class, 'channel_id');
+ }
+
+ /**
+ * Find channel by code.
+ */
+ public static function byCode(string $code, ?int $workspaceId = null): ?self
+ {
+ return static::where('code', $code)
+ ->where(function ($query) use ($workspaceId) {
+ $query->whereNull('workspace_id');
+ if ($workspaceId !== null) {
+ $query->orWhere('workspace_id', $workspaceId);
+ }
+ })
+ ->orderByRaw('workspace_id IS NULL') // Workspace-specific first
+ ->first();
+ }
+
+ /**
+ * Build inheritance chain (most specific to least).
+ *
+ * Includes cycle detection to prevent infinite loops from data corruption.
+ *
+ * @return Collection
+ */
+ public function inheritanceChain(): Collection
+ {
+ $chain = new Collection([$this]);
+ $current = $this;
+ $seen = [$this->id => true];
+
+ while ($current->parent_id !== null) {
+ if (isset($seen[$current->parent_id])) {
+ Log::error('Circular reference detected in channel inheritance', [
+ 'channel_id' => $this->id,
+ 'cycle_at' => $current->parent_id,
+ ]);
+ break;
+ }
+
+ $parent = $current->parent;
+ if ($parent === null) {
+ break;
+ }
+
+ $seen[$parent->id] = true;
+ $chain->push($parent);
+ $current = $parent;
+ }
+
+ return $chain;
+ }
+
+ /**
+ * Get all channel codes in inheritance chain.
+ *
+ * @return array
+ */
+ public function inheritanceCodes(): array
+ {
+ return $this->inheritanceChain()->pluck('code')->all();
+ }
+
+ /**
+ * Check if this channel inherits from another.
+ */
+ public function inheritsFrom(string $code): bool
+ {
+ return in_array($code, $this->inheritanceCodes(), true);
+ }
+
+ /**
+ * Is this a system channel (available to all workspaces)?
+ */
+ public function isSystem(): bool
+ {
+ return $this->workspace_id === null;
+ }
+
+ /**
+ * Get metadata value.
+ */
+ public function meta(string $key, mixed $default = null): mixed
+ {
+ return data_get($this->metadata, $key, $default);
+ }
+
+ /**
+ * Ensure a channel exists.
+ */
+ public static function ensure(
+ string $code,
+ string $name,
+ ?string $parentCode = null,
+ ?int $workspaceId = null,
+ ?array $metadata = null,
+ ): self {
+ $parentId = null;
+ if ($parentCode !== null) {
+ $parent = static::byCode($parentCode, $workspaceId);
+ $parentId = $parent?->id;
+ }
+
+ return static::firstOrCreate(
+ [
+ 'code' => $code,
+ 'workspace_id' => $workspaceId,
+ ],
+ [
+ 'name' => $name,
+ 'parent_id' => $parentId,
+ 'metadata' => $metadata,
+ ]
+ );
+ }
+}
diff --git a/src/Core/Config/Models/ConfigKey.php b/src/Core/Config/Models/ConfigKey.php
new file mode 100644
index 0000000..798c012
--- /dev/null
+++ b/src/Core/Config/Models/ConfigKey.php
@@ -0,0 +1,117 @@
+ ConfigType::class,
+ 'default_value' => 'json',
+ 'is_sensitive' => 'boolean',
+ ];
+
+ /**
+ * Check if this key contains sensitive data that should be encrypted.
+ */
+ public function isSensitive(): bool
+ {
+ return $this->is_sensitive ?? false;
+ }
+
+ /**
+ * Parent key (for hierarchical grouping).
+ */
+ public function parent(): BelongsTo
+ {
+ return $this->belongsTo(self::class, 'parent_id');
+ }
+
+ /**
+ * Child keys.
+ */
+ public function children(): HasMany
+ {
+ return $this->hasMany(self::class, 'parent_id');
+ }
+
+ /**
+ * Values assigned to this key across profiles.
+ */
+ public function values(): HasMany
+ {
+ return $this->hasMany(ConfigValue::class, 'key_id');
+ }
+
+ /**
+ * Get typed default value.
+ */
+ public function getTypedDefault(): mixed
+ {
+ if ($this->default_value === null) {
+ return $this->type->default();
+ }
+
+ return $this->type->cast($this->default_value);
+ }
+
+ /**
+ * Find key by code.
+ */
+ public static function byCode(string $code): ?self
+ {
+ return static::where('code', $code)->first();
+ }
+
+ /**
+ * Get all keys for a category.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forCategory(string $category): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('category', $category)->get();
+ }
+}
diff --git a/src/Core/Config/Models/ConfigProfile.php b/src/Core/Config/Models/ConfigProfile.php
new file mode 100644
index 0000000..c54a101
--- /dev/null
+++ b/src/Core/Config/Models/ConfigProfile.php
@@ -0,0 +1,148 @@
+ ScopeType::class,
+ 'priority' => 'integer',
+ ];
+
+ /**
+ * Parent profile (for profile-level inheritance).
+ */
+ public function parent(): BelongsTo
+ {
+ return $this->belongsTo(self::class, 'parent_profile_id');
+ }
+
+ /**
+ * Child profiles.
+ */
+ public function children(): HasMany
+ {
+ return $this->hasMany(self::class, 'parent_profile_id');
+ }
+
+ /**
+ * Config values in this profile.
+ */
+ public function values(): HasMany
+ {
+ return $this->hasMany(ConfigValue::class, 'profile_id');
+ }
+
+ /**
+ * Get system profile.
+ */
+ public static function system(): ?self
+ {
+ return static::where('scope_type', ScopeType::SYSTEM)
+ ->whereNull('scope_id')
+ ->orderByDesc('priority')
+ ->first();
+ }
+
+ /**
+ * Get profiles for a scope.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forScope(ScopeType $type, ?int $scopeId = null): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('scope_type', $type)
+ ->where('scope_id', $scopeId)
+ ->orderByDesc('priority')
+ ->get();
+ }
+
+ /**
+ * Get profile for workspace.
+ */
+ public static function forWorkspace(int $workspaceId): ?self
+ {
+ return static::where('scope_type', ScopeType::WORKSPACE)
+ ->where('scope_id', $workspaceId)
+ ->orderByDesc('priority')
+ ->first();
+ }
+
+ /**
+ * Get or create system profile.
+ */
+ public static function ensureSystem(): self
+ {
+ return static::firstOrCreate(
+ [
+ 'scope_type' => ScopeType::SYSTEM,
+ 'scope_id' => null,
+ ],
+ [
+ 'name' => 'System Default',
+ 'priority' => 0,
+ ]
+ );
+ }
+
+ /**
+ * Get or create workspace profile.
+ */
+ public static function ensureWorkspace(int $workspaceId, ?int $parentProfileId = null): self
+ {
+ return static::firstOrCreate(
+ [
+ 'scope_type' => ScopeType::WORKSPACE,
+ 'scope_id' => $workspaceId,
+ ],
+ [
+ 'name' => "Workspace {$workspaceId}",
+ 'parent_profile_id' => $parentProfileId,
+ 'priority' => 0,
+ ]
+ );
+ }
+}
diff --git a/src/Core/Config/Models/ConfigResolved.php b/src/Core/Config/Models/ConfigResolved.php
new file mode 100644
index 0000000..0248281
--- /dev/null
+++ b/src/Core/Config/Models/ConfigResolved.php
@@ -0,0 +1,236 @@
+ 'json',
+ 'locked' => 'boolean',
+ 'virtual' => 'boolean',
+ 'computed_at' => 'datetime',
+ ];
+
+ /**
+ * Workspace this resolution is for (null = system).
+ *
+ * Requires Core\Tenant module to be installed.
+ */
+ public function workspace(): BelongsTo
+ {
+ if (class_exists(\Core\Tenant\Models\Workspace::class)) {
+ return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
+ }
+
+ // Return a null relationship when Tenant module is not installed
+ return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0');
+ }
+
+ /**
+ * Channel this resolution is for (null = all channels).
+ */
+ public function channel(): BelongsTo
+ {
+ return $this->belongsTo(Channel::class);
+ }
+
+ /**
+ * Profile that provided this value.
+ */
+ public function sourceProfile(): BelongsTo
+ {
+ return $this->belongsTo(ConfigProfile::class, 'source_profile_id');
+ }
+
+ /**
+ * Get the resolved value with proper type casting.
+ */
+ public function getTypedValue(): mixed
+ {
+ $type = ConfigType::tryFrom($this->type) ?? ConfigType::STRING;
+
+ return $type->cast($this->value);
+ }
+
+ /**
+ * Convert to ConfigResult for API compatibility.
+ */
+ public function toResult(): ConfigResult
+ {
+ $type = ConfigType::tryFrom($this->type) ?? ConfigType::STRING;
+
+ if ($this->virtual) {
+ return ConfigResult::virtual(
+ key: $this->key_code,
+ value: $this->value,
+ type: $type,
+ );
+ }
+
+ // Determine scope type from source profile
+ $scopeType = null;
+ if ($this->source_profile_id !== null) {
+ $scopeType = $this->sourceProfile?->scope_type;
+ }
+
+ return new ConfigResult(
+ key: $this->key_code,
+ value: $type->cast($this->value),
+ type: $type,
+ found: true,
+ locked: $this->locked,
+ virtual: $this->virtual,
+ resolvedFrom: $scopeType,
+ profileId: $this->source_profile_id,
+ channelId: $this->source_channel_id,
+ );
+ }
+
+ /**
+ * Look up a resolved config value.
+ *
+ * This is THE read path - single indexed lookup.
+ */
+ public static function lookup(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): ?self
+ {
+ return static::where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->where('key_code', $keyCode)
+ ->first();
+ }
+
+ /**
+ * Get all resolved config for a scope.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forScope(?int $workspaceId = null, ?int $channelId = null): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->get();
+ }
+
+ /**
+ * Store a resolved config value.
+ */
+ public static function store(
+ string $keyCode,
+ mixed $value,
+ ConfigType $type,
+ ?int $workspaceId = null,
+ ?int $channelId = null,
+ bool $locked = false,
+ ?int $sourceProfileId = null,
+ ?int $sourceChannelId = null,
+ bool $virtual = false,
+ ): self {
+ return static::updateOrCreate(
+ [
+ 'workspace_id' => $workspaceId,
+ 'channel_id' => $channelId,
+ 'key_code' => $keyCode,
+ ],
+ [
+ 'value' => $value,
+ 'type' => $type->value,
+ 'locked' => $locked,
+ 'source_profile_id' => $sourceProfileId,
+ 'source_channel_id' => $sourceChannelId,
+ 'virtual' => $virtual,
+ 'computed_at' => now(),
+ ]
+ );
+ }
+
+ /**
+ * Clear resolved config for a scope.
+ */
+ public static function clearScope(?int $workspaceId = null, ?int $channelId = null): int
+ {
+ return static::where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->delete();
+ }
+
+ /**
+ * Clear all resolved config for a workspace (all channels).
+ */
+ public static function clearWorkspace(?int $workspaceId = null): int
+ {
+ return static::where('workspace_id', $workspaceId)->delete();
+ }
+
+ /**
+ * Clear resolved config for a specific key across all scopes.
+ */
+ public static function clearKey(string $keyCode): int
+ {
+ return static::where('key_code', $keyCode)->delete();
+ }
+
+ /**
+ * Composite key handling for Eloquent.
+ */
+ protected function setKeysForSaveQuery($query)
+ {
+ $query->where('workspace_id', $this->workspace_id)
+ ->where('channel_id', $this->channel_id)
+ ->where('key_code', $this->key_code);
+
+ return $query;
+ }
+}
diff --git a/src/Core/Config/Models/ConfigValue.php b/src/Core/Config/Models/ConfigValue.php
new file mode 100644
index 0000000..7ab2e55
--- /dev/null
+++ b/src/Core/Config/Models/ConfigValue.php
@@ -0,0 +1,272 @@
+ 'boolean',
+ ];
+
+ /**
+ * Encrypted value marker prefix.
+ *
+ * Used to detect if a stored value is encrypted.
+ */
+ protected const ENCRYPTED_PREFIX = 'encrypted:';
+
+ /**
+ * Get the value attribute with automatic decryption for sensitive keys.
+ */
+ public function getValueAttribute(mixed $value): mixed
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ // Decode JSON first
+ $decoded = is_string($value) ? json_decode($value, true) : $value;
+
+ // Check if this is an encrypted value
+ if (is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX)) {
+ try {
+ $encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX));
+
+ return json_decode(Crypt::decryptString($encrypted), true);
+ } catch (\Illuminate\Contracts\Encryption\DecryptException) {
+ // Return null if decryption fails (key rotation, corruption, etc.)
+ return null;
+ }
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Set the value attribute with automatic encryption for sensitive keys.
+ */
+ public function setValueAttribute(mixed $value): void
+ {
+ // Check if the key is sensitive (need to load it if not already)
+ $key = $this->relationLoaded('key')
+ ? $this->getRelation('key')
+ : ($this->key_id ? ConfigKey::find($this->key_id) : null);
+
+ if ($key?->isSensitive() && $value !== null) {
+ // Encrypt the value
+ $jsonValue = json_encode($value);
+ $encrypted = Crypt::encryptString($jsonValue);
+ $this->attributes['value'] = json_encode(self::ENCRYPTED_PREFIX.$encrypted);
+ } else {
+ // Store as regular JSON
+ $this->attributes['value'] = json_encode($value);
+ }
+ }
+
+ /**
+ * Check if the current stored value is encrypted.
+ */
+ public function isEncrypted(): bool
+ {
+ $raw = $this->attributes['value'] ?? null;
+ if ($raw === null) {
+ return false;
+ }
+
+ $decoded = json_decode($raw, true);
+
+ return is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX);
+ }
+
+ /**
+ * The profile this value belongs to.
+ */
+ public function profile(): BelongsTo
+ {
+ return $this->belongsTo(ConfigProfile::class, 'profile_id');
+ }
+
+ /**
+ * The key this value is for.
+ */
+ public function key(): BelongsTo
+ {
+ return $this->belongsTo(ConfigKey::class, 'key_id');
+ }
+
+ /**
+ * Profile this value was inherited from (if any).
+ */
+ public function inheritedFromProfile(): BelongsTo
+ {
+ return $this->belongsTo(ConfigProfile::class, 'inherited_from');
+ }
+
+ /**
+ * The channel this value is scoped to (null = all channels).
+ */
+ public function channel(): BelongsTo
+ {
+ return $this->belongsTo(Channel::class, 'channel_id');
+ }
+
+ /**
+ * Get typed value.
+ */
+ public function getTypedValue(): mixed
+ {
+ $key = $this->key;
+
+ if ($key === null) {
+ return $this->value;
+ }
+
+ return $key->type->cast($this->value);
+ }
+
+ /**
+ * Check if this value is locked (FINAL).
+ */
+ public function isLocked(): bool
+ {
+ return $this->locked;
+ }
+
+ /**
+ * Check if this value was inherited.
+ */
+ public function isInherited(): bool
+ {
+ return $this->inherited_from !== null;
+ }
+
+ /**
+ * Find value for a profile, key, and optional channel.
+ */
+ public static function findValue(int $profileId, int $keyId, ?int $channelId = null): ?self
+ {
+ return static::where('profile_id', $profileId)
+ ->where('key_id', $keyId)
+ ->where('channel_id', $channelId)
+ ->first();
+ }
+
+ /**
+ * Set or update a value.
+ *
+ * Automatically invalidates the resolved hash so the next read
+ * will recompute the value with the new setting.
+ */
+ public static function setValue(
+ int $profileId,
+ int $keyId,
+ mixed $value,
+ bool $locked = false,
+ ?int $inheritedFrom = null,
+ ?int $channelId = null,
+ ): self {
+ $configValue = static::updateOrCreate(
+ [
+ 'profile_id' => $profileId,
+ 'key_id' => $keyId,
+ 'channel_id' => $channelId,
+ ],
+ [
+ 'value' => $value,
+ 'locked' => $locked,
+ 'inherited_from' => $inheritedFrom,
+ ]
+ );
+
+ // Invalidate hash for this key (all scopes)
+ // The value will be recomputed on next access
+ $key = ConfigKey::find($keyId);
+ if ($key === null) {
+ return $configValue;
+ }
+
+ ConfigResolver::clear($key->code);
+
+ // Also clear from resolved table for this scope
+ $profile = ConfigProfile::find($profileId);
+ if ($profile === null) {
+ return $configValue;
+ }
+
+ $workspaceId = $profile->scope_type === ScopeType::WORKSPACE
+ ? $profile->scope_id
+ : null;
+
+ ConfigResolved::where('key_code', $key->code)
+ ->where('workspace_id', $workspaceId)
+ ->where('channel_id', $channelId)
+ ->delete();
+
+ return $configValue;
+ }
+
+ /**
+ * Get all values for a key across profiles and channels.
+ *
+ * Used for batch resolution to avoid N+1.
+ *
+ * @param array $profileIds
+ * @param array|null $channelIds Include null for "all channels" values
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('key_id', $keyId)
+ ->whereIn('profile_id', $profileIds)
+ ->when($channelIds !== null, function ($query) use ($channelIds) {
+ $query->where(function ($q) use ($channelIds) {
+ $q->whereIn('channel_id', $channelIds)
+ ->orWhereNull('channel_id');
+ });
+ })
+ ->get();
+ }
+}
diff --git a/src/Core/Config/Models/ConfigVersion.php b/src/Core/Config/Models/ConfigVersion.php
new file mode 100644
index 0000000..190a65f
--- /dev/null
+++ b/src/Core/Config/Models/ConfigVersion.php
@@ -0,0 +1,185 @@
+ 'datetime',
+ ];
+
+ /**
+ * The profile this version belongs to.
+ */
+ public function profile(): BelongsTo
+ {
+ return $this->belongsTo(ConfigProfile::class, 'profile_id');
+ }
+
+ /**
+ * Workspace this version is for (null = system).
+ *
+ * Requires Core\Tenant module to be installed.
+ */
+ public function workspace(): BelongsTo
+ {
+ if (class_exists(\Core\Tenant\Models\Workspace::class)) {
+ return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
+ }
+
+ // Return a null relationship when Tenant module is not installed
+ return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0');
+ }
+
+ /**
+ * Get the parsed snapshot data.
+ *
+ * @return array
+ */
+ public function getSnapshotData(): array
+ {
+ return json_decode($this->snapshot, true) ?? [];
+ }
+
+ /**
+ * Get the config values from the snapshot.
+ *
+ * @return array
+ */
+ public function getValues(): array
+ {
+ $data = $this->getSnapshotData();
+
+ return $data['values'] ?? [];
+ }
+
+ /**
+ * Get a specific value from the snapshot.
+ *
+ * @param string $key Config key code
+ * @return mixed|null The value or null if not found
+ */
+ public function getValue(string $key): mixed
+ {
+ $values = $this->getValues();
+
+ foreach ($values as $value) {
+ if ($value['key'] === $key) {
+ return $value['value'];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a key exists in the snapshot.
+ *
+ * @param string $key Config key code
+ */
+ public function hasKey(string $key): bool
+ {
+ $values = $this->getValues();
+
+ foreach ($values as $value) {
+ if ($value['key'] === $key) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get versions for a scope.
+ *
+ * @param int|null $workspaceId Workspace ID or null for system
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function forScope(?int $workspaceId = null): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('workspace_id', $workspaceId)
+ ->orderByDesc('created_at')
+ ->get();
+ }
+
+ /**
+ * Get the latest version for a scope.
+ *
+ * @param int|null $workspaceId Workspace ID or null for system
+ */
+ public static function latest(?int $workspaceId = null): ?self
+ {
+ return static::where('workspace_id', $workspaceId)
+ ->orderByDesc('created_at')
+ ->first();
+ }
+
+ /**
+ * Get versions created by a specific author.
+ *
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function byAuthor(string $author): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::where('author', $author)
+ ->orderByDesc('created_at')
+ ->get();
+ }
+
+ /**
+ * Get versions created within a date range.
+ *
+ * @param \Carbon\Carbon $from Start date
+ * @param \Carbon\Carbon $to End date
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ public static function inDateRange(\Carbon\Carbon $from, \Carbon\Carbon $to): \Illuminate\Database\Eloquent\Collection
+ {
+ return static::whereBetween('created_at', [$from, $to])
+ ->orderByDesc('created_at')
+ ->get();
+ }
+}
diff --git a/src/Core/Config/Routes/admin.php b/src/Core/Config/Routes/admin.php
new file mode 100644
index 0000000..7863143
--- /dev/null
+++ b/src/Core/Config/Routes/admin.php
@@ -0,0 +1,19 @@
+prefix('admin')
+ ->group(function () {
+ Route::get('/config', ConfigPanel::class)->name('admin.config');
+ });
diff --git a/src/Core/Config/Tests/Feature/ConfigServiceTest.php b/src/Core/Config/Tests/Feature/ConfigServiceTest.php
new file mode 100644
index 0000000..d9e3bf7
--- /dev/null
+++ b/src/Core/Config/Tests/Feature/ConfigServiceTest.php
@@ -0,0 +1,413 @@
+systemProfile = ConfigProfile::ensureSystem();
+
+ // Create a test workspace
+ $this->workspace = Workspace::factory()->create();
+ $this->workspaceProfile = ConfigProfile::ensureWorkspace($this->workspace->id, $this->systemProfile->id);
+
+ // Create test config keys
+ $this->stringKey = ConfigKey::create([
+ 'code' => 'test.string_key',
+ 'type' => ConfigType::STRING,
+ 'category' => 'test',
+ 'description' => 'A test string key',
+ 'default_value' => 'default_string',
+ ]);
+
+ $this->boolKey = ConfigKey::create([
+ 'code' => 'test.bool_key',
+ 'type' => ConfigType::BOOL,
+ 'category' => 'test',
+ 'description' => 'A test boolean key',
+ 'default_value' => false,
+ ]);
+
+ $this->intKey = ConfigKey::create([
+ 'code' => 'test.int_key',
+ 'type' => ConfigType::INT,
+ 'category' => 'test',
+ 'description' => 'A test integer key',
+ 'default_value' => 10,
+ ]);
+
+ $this->service = app(ConfigService::class);
+ $this->resolver = app(ConfigResolver::class);
+});
+
+describe('ConfigKey model', function () {
+ it('creates keys with correct types', function () {
+ expect($this->stringKey->type)->toBe(ConfigType::STRING);
+ expect($this->boolKey->type)->toBe(ConfigType::BOOL);
+ expect($this->intKey->type)->toBe(ConfigType::INT);
+ });
+
+ it('returns typed defaults', function () {
+ expect($this->stringKey->getTypedDefault())->toBe('default_string');
+ expect($this->boolKey->getTypedDefault())->toBe(false);
+ expect($this->intKey->getTypedDefault())->toBe(10);
+ });
+
+ it('finds keys by code', function () {
+ $found = ConfigKey::byCode('test.string_key');
+
+ expect($found)->not->toBeNull();
+ expect($found->id)->toBe($this->stringKey->id);
+ });
+});
+
+describe('ConfigProfile model', function () {
+ it('creates system profile', function () {
+ expect($this->systemProfile->scope_type)->toBe(ScopeType::SYSTEM);
+ expect($this->systemProfile->scope_id)->toBeNull();
+ });
+
+ it('creates workspace profile', function () {
+ expect($this->workspaceProfile->scope_type)->toBe(ScopeType::WORKSPACE);
+ expect($this->workspaceProfile->scope_id)->toBe($this->workspace->id);
+ });
+
+ it('links workspace profile to system parent', function () {
+ expect($this->workspaceProfile->parent_profile_id)->toBe($this->systemProfile->id);
+ });
+});
+
+describe('ConfigResolver', function () {
+ it('resolves to default when no value set', function () {
+ $result = $this->resolver->resolve('test.string_key', null);
+
+ expect($result->found)->toBeFalse();
+ expect($result->get())->toBe('default_string');
+ });
+
+ it('resolves system value', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system_value');
+
+ $result = $this->resolver->resolve('test.string_key', null);
+
+ expect($result->found)->toBeTrue();
+ expect($result->get())->toBe('system_value');
+ expect($result->resolvedFrom)->toBe(ScopeType::SYSTEM);
+ });
+
+ it('workspace overrides system value', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system_value');
+ ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace_value');
+
+ $result = $this->resolver->resolve('test.string_key', $this->workspace);
+
+ expect($result->get())->toBe('workspace_value');
+ expect($result->resolvedFrom)->toBe(ScopeType::WORKSPACE);
+ });
+
+ it('respects FINAL lock from system', function () {
+ // Set locked value at system level
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'locked_value', locked: true);
+
+ // Try to override at workspace level
+ ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace_value');
+
+ $result = $this->resolver->resolve('test.string_key', $this->workspace);
+
+ // Should get the locked system value
+ expect($result->get())->toBe('locked_value');
+ expect($result->isLocked())->toBeTrue();
+ expect($result->resolvedFrom)->toBe(ScopeType::SYSTEM);
+ });
+
+ it('returns unconfigured for unknown keys', function () {
+ $result = $this->resolver->resolve('nonexistent.key', null);
+
+ expect($result->found)->toBeFalse();
+ expect($result->isConfigured())->toBeFalse();
+ });
+});
+
+describe('ConfigService with materialised resolution', function () {
+ it('gets config value with default', function () {
+ $value = $this->service->get('test.string_key', 'fallback');
+
+ expect($value)->toBe('default_string');
+ });
+
+ it('gets config value from resolved table after prime', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'db_value');
+ $this->service->prime();
+
+ $value = $this->service->get('test.string_key');
+
+ expect($value)->toBe('db_value');
+ });
+
+ it('reads from materialised table not source', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'original');
+ $this->service->prime();
+
+ // Update source directly (bypassing service)
+ ConfigValue::where('profile_id', $this->systemProfile->id)
+ ->where('key_id', $this->stringKey->id)
+ ->update(['value' => json_encode('changed')]);
+
+ // Should still return materialised value
+ $value = $this->service->get('test.string_key');
+
+ expect($value)->toBe('original');
+ });
+
+ it('updates materialised table on set', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'initial');
+ $this->service->prime();
+
+ // Set new value via service
+ $this->service->set('test.string_key', 'updated', $this->systemProfile);
+
+ // Should get new value
+ $value = $this->service->get('test.string_key');
+
+ expect($value)->toBe('updated');
+ });
+
+ it('checks if configured', function () {
+ expect($this->service->isConfigured('test.string_key'))->toBeFalse();
+
+ $this->service->set('test.string_key', 'some_value', $this->systemProfile);
+
+ expect($this->service->isConfigured('test.string_key'))->toBeTrue();
+ });
+
+ it('checks if prefix is configured', function () {
+ expect($this->service->isConfigured('test'))->toBeFalse();
+
+ $this->service->set('test.string_key', 'value', $this->systemProfile);
+
+ expect($this->service->isConfigured('test'))->toBeTrue();
+ });
+
+ it('locks and unlocks values', function () {
+ $this->service->set('test.string_key', 'value', $this->systemProfile);
+ $this->service->lock('test.string_key', $this->systemProfile);
+
+ $result = $this->service->resolve('test.string_key');
+ expect($result->isLocked())->toBeTrue();
+
+ $this->service->unlock('test.string_key', $this->systemProfile);
+ $result = $this->service->resolve('test.string_key');
+ expect($result->isLocked())->toBeFalse();
+ });
+
+ it('gets all config values for scope', function () {
+ $this->service->set('test.string_key', 'string_val', $this->systemProfile);
+ $this->service->set('test.bool_key', true, $this->systemProfile);
+ $this->service->set('test.int_key', 42, $this->systemProfile);
+ $this->service->prime();
+
+ $all = $this->service->all();
+
+ expect($all['test.string_key'])->toBe('string_val');
+ expect($all['test.bool_key'])->toBe(true);
+ expect($all['test.int_key'])->toBe(42);
+ });
+
+ it('primes materialised table for workspace', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system');
+ ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace');
+
+ $this->service->prime();
+ $this->service->prime($this->workspace);
+
+ // Workspace context should get override
+ $this->service->setContext($this->workspace);
+ $wsValue = $this->service->get('test.string_key');
+ expect($wsValue)->toBe('workspace');
+
+ // System context should get system value
+ $this->service->setContext(null);
+ $sysValue = $this->service->get('test.string_key');
+ expect($sysValue)->toBe('system');
+ });
+});
+
+describe('ConfigResolved model', function () {
+ it('stores and retrieves resolved values', function () {
+ ConfigResolved::store(
+ keyCode: 'test.key',
+ value: 'test_value',
+ type: ConfigType::STRING,
+ workspaceId: null,
+ channelId: null,
+ );
+
+ $resolved = ConfigResolved::lookup('test.key');
+
+ expect($resolved)->not->toBeNull();
+ expect($resolved->value)->toBe('test_value');
+ });
+
+ it('clears scope correctly', function () {
+ ConfigResolved::store('key1', 'v1', ConfigType::STRING);
+ ConfigResolved::store('key2', 'v2', ConfigType::STRING, workspaceId: $this->workspace->id);
+
+ ConfigResolved::clearScope(null, null);
+
+ expect(ConfigResolved::lookup('key1'))->toBeNull();
+ expect(ConfigResolved::lookup('key2', $this->workspace->id))->not->toBeNull();
+ });
+});
+
+describe('Single hash', function () {
+ it('loads scope into hash on first access', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'hash_test');
+ $this->service->prime();
+
+ // Clear hash but keep DB
+ ConfigResolver::clearAll();
+
+ expect(ConfigResolver::isLoaded())->toBeFalse();
+ expect(count(ConfigResolver::all()))->toBe(0);
+
+ // First access should lazy-load entire scope
+ $this->service->get('test.string_key');
+
+ expect(ConfigResolver::isLoaded())->toBeTrue();
+ expect(count(ConfigResolver::all()))->toBeGreaterThan(0);
+ });
+
+ it('subsequent reads hit hash not database', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'hash_read');
+ $this->service->prime();
+
+ // Clear and reload
+ ConfigResolver::clearAll();
+
+ // First read loads scope
+ $this->service->get('test.string_key');
+
+ // Value is now in hash
+ expect(ConfigResolver::has('test.string_key'))->toBeTrue();
+
+ // Get the value directly from hash
+ $hashValue = ConfigResolver::get('test.string_key');
+ expect($hashValue)->toBe('hash_read');
+ });
+
+ it('lazy primes uncached keys into hash', function () {
+ // Set value but don't prime
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'lazy_prime');
+
+ // Clear everything
+ ConfigResolver::clearAll();
+
+ // Access should compute and store in hash
+ $value = $this->service->get('test.string_key');
+ expect($value)->toBe('lazy_prime');
+
+ // Now it's in hash
+ expect(ConfigResolver::has('test.string_key'))->toBeTrue();
+ });
+
+ it('invalidation clears hash and database', function () {
+ ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'to_invalidate');
+ $this->service->prime();
+
+ // Verify in hash
+ expect(ConfigResolver::has('test.string_key'))->toBeTrue();
+
+ // Invalidate
+ $this->service->invalidateKey('test.string_key');
+
+ // Cleared from hash
+ expect(ConfigResolver::has('test.string_key'))->toBeFalse();
+ });
+});
+
+describe('ConfigResult', function () {
+ it('converts to array for serialisation', function () {
+ $result = ConfigResult::found(
+ key: 'test.key',
+ value: 'test_value',
+ type: ConfigType::STRING,
+ locked: true,
+ resolvedFrom: ScopeType::SYSTEM,
+ profileId: 1,
+ );
+
+ $array = $result->toArray();
+
+ expect($array['key'])->toBe('test.key');
+ expect($array['value'])->toBe('test_value');
+ expect($array['type'])->toBe('string');
+ expect($array['locked'])->toBeTrue();
+ });
+
+ it('reconstructs from array', function () {
+ $original = ConfigResult::found(
+ key: 'test.key',
+ value: 42,
+ type: ConfigType::INT,
+ locked: false,
+ resolvedFrom: ScopeType::WORKSPACE,
+ profileId: 5,
+ );
+
+ $reconstructed = ConfigResult::fromArray($original->toArray());
+
+ expect($reconstructed->key)->toBe($original->key);
+ expect($reconstructed->value)->toBe($original->value);
+ expect($reconstructed->type)->toBe($original->type);
+ expect($reconstructed->locked)->toBe($original->locked);
+ expect($reconstructed->resolvedFrom)->toBe($original->resolvedFrom);
+ });
+
+ it('provides typed accessors', function () {
+ $result = ConfigResult::found(
+ key: 'test.key',
+ value: '42',
+ type: ConfigType::STRING,
+ locked: false,
+ resolvedFrom: ScopeType::SYSTEM,
+ profileId: 1,
+ );
+
+ expect($result->string())->toBe('42');
+ expect($result->int())->toBe(42);
+ });
+
+ it('supports virtual results', function () {
+ $result = ConfigResult::virtual(
+ key: 'bio.page.title',
+ value: 'My Bio Page',
+ type: ConfigType::STRING,
+ );
+
+ expect($result->isVirtual())->toBeTrue();
+ expect($result->found)->toBeTrue();
+ expect($result->get())->toBe('My Bio Page');
+ });
+});
diff --git a/src/Core/Config/VersionDiff.php b/src/Core/Config/VersionDiff.php
new file mode 100644
index 0000000..e3797f4
--- /dev/null
+++ b/src/Core/Config/VersionDiff.php
@@ -0,0 +1,221 @@
+
+ */
+ protected array $added = [];
+
+ /**
+ * Keys removed in the new version.
+ *
+ * @var array
+ */
+ protected array $removed = [];
+
+ /**
+ * Keys with changed values.
+ *
+ * @var array
+ */
+ protected array $changed = [];
+
+ /**
+ * Keys with changed lock status.
+ *
+ * @var array
+ */
+ protected array $lockChanged = [];
+
+ /**
+ * Add an added key.
+ *
+ * @param string $key The config key
+ * @param mixed $value The new value
+ */
+ public function addAdded(string $key, mixed $value): void
+ {
+ $this->added[] = ['key' => $key, 'value' => $value];
+ }
+
+ /**
+ * Add a removed key.
+ *
+ * @param string $key The config key
+ * @param mixed $value The old value
+ */
+ public function addRemoved(string $key, mixed $value): void
+ {
+ $this->removed[] = ['key' => $key, 'value' => $value];
+ }
+
+ /**
+ * Add a changed key.
+ *
+ * @param string $key The config key
+ * @param mixed $oldValue The old value
+ * @param mixed $newValue The new value
+ */
+ public function addChanged(string $key, mixed $oldValue, mixed $newValue): void
+ {
+ $this->changed[] = ['key' => $key, 'old' => $oldValue, 'new' => $newValue];
+ }
+
+ /**
+ * Add a lock status change.
+ *
+ * @param string $key The config key
+ * @param bool $oldLocked Old lock status
+ * @param bool $newLocked New lock status
+ */
+ public function addLockChanged(string $key, bool $oldLocked, bool $newLocked): void
+ {
+ $this->lockChanged[] = ['key' => $key, 'old' => $oldLocked, 'new' => $newLocked];
+ }
+
+ /**
+ * Get added keys.
+ *
+ * @return array
+ */
+ public function getAdded(): array
+ {
+ return $this->added;
+ }
+
+ /**
+ * Get removed keys.
+ *
+ * @return array
+ */
+ public function getRemoved(): array
+ {
+ return $this->removed;
+ }
+
+ /**
+ * Get changed keys.
+ *
+ * @return array
+ */
+ public function getChanged(): array
+ {
+ return $this->changed;
+ }
+
+ /**
+ * Get lock status changes.
+ *
+ * @return array
+ */
+ public function getLockChanged(): array
+ {
+ return $this->lockChanged;
+ }
+
+ /**
+ * Check if there are any differences.
+ */
+ public function hasDifferences(): bool
+ {
+ return ! empty($this->added)
+ || ! empty($this->removed)
+ || ! empty($this->changed)
+ || ! empty($this->lockChanged);
+ }
+
+ /**
+ * Check if there are no differences.
+ */
+ public function isEmpty(): bool
+ {
+ return ! $this->hasDifferences();
+ }
+
+ /**
+ * Get total count of differences.
+ */
+ public function count(): int
+ {
+ return count($this->added)
+ + count($this->removed)
+ + count($this->changed)
+ + count($this->lockChanged);
+ }
+
+ /**
+ * Get summary string.
+ */
+ public function getSummary(): string
+ {
+ if ($this->isEmpty()) {
+ return 'No differences';
+ }
+
+ $parts = [];
+
+ if (count($this->added) > 0) {
+ $parts[] = count($this->added).' added';
+ }
+
+ if (count($this->removed) > 0) {
+ $parts[] = count($this->removed).' removed';
+ }
+
+ if (count($this->changed) > 0) {
+ $parts[] = count($this->changed).' changed';
+ }
+
+ if (count($this->lockChanged) > 0) {
+ $parts[] = count($this->lockChanged).' lock changes';
+ }
+
+ return implode(', ', $parts);
+ }
+
+ /**
+ * Convert to array for JSON serialization.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'has_differences' => $this->hasDifferences(),
+ 'summary' => $this->getSummary(),
+ 'added' => $this->added,
+ 'removed' => $this->removed,
+ 'changed' => $this->changed,
+ 'lock_changed' => $this->lockChanged,
+ 'counts' => [
+ 'added' => count($this->added),
+ 'removed' => count($this->removed),
+ 'changed' => count($this->changed),
+ 'lock_changed' => count($this->lockChanged),
+ 'total' => $this->count(),
+ ],
+ ];
+ }
+}
diff --git a/src/Core/Config/View/Blade/admin/config-panel.blade.php b/src/Core/Config/View/Blade/admin/config-panel.blade.php
new file mode 100644
index 0000000..a7af327
--- /dev/null
+++ b/src/Core/Config/View/Blade/admin/config-panel.blade.php
@@ -0,0 +1,145 @@
+
+
Configuration
+
+
+
+ System
+ Workspace
+
+
+ @if ($scope === 'workspace')
+
+ Select workspace
+ @foreach ($this->workspaces as $ws)
+ {{ $ws->name }}
+ @endforeach
+
+ @endif
+
+
+ All categories
+ @foreach ($this->categories as $cat)
+ {{ $cat }}
+ @endforeach
+
+
+
+
+
+ @if ($scope === 'workspace' && $this->selectedWorkspace)
+
+ Editing configuration for workspace: {{ $this->selectedWorkspace->name }} .
+ Values inherit from system unless overridden.
+
+ @endif
+
+
+
+ Key
+ Type
+ Value
+ Status
+
+
+
+
+ @foreach ($this->keys as $key)
+ @php
+ $isInherited = $this->isInherited($key);
+ $isLockedByParent = $this->isLockedByParent($key);
+ $isLocked = $this->isLocked($key);
+ @endphp
+
+
+
+
{{ $key->code }}
+ @if ($key->description)
+
{{ $key->description }}
+ @endif
+
+
+
+
+ {{ $key->type->value }}
+
+
+
+ @if ($editingKeyId === $key->id)
+ @if ($key->type === \Core\Config\Enums\ConfigType::BOOL)
+
+ @elseif ($key->type === \Core\Config\Enums\ConfigType::INT)
+
+ @elseif ($key->type === \Core\Config\Enums\ConfigType::JSON || $key->type === \Core\Config\Enums\ConfigType::ARRAY)
+
+ @else
+
+ @endif
+ @else
+
+ @if (is_array($this->getValue($key)))
+ {{ json_encode($this->getValue($key)) }}
+ @elseif (is_bool($this->getValue($key)))
+ {{ $this->getValue($key) ? 'true' : 'false' }}
+ @else
+ {{ $this->getValue($key) ?? '-' }}
+ @endif
+
+ @endif
+
+
+
+
+ @if ($editingKeyId === $key->id && !$isLockedByParent)
+
+ @else
+ @if ($isLockedByParent)
+ System locked
+ @elseif ($isLocked)
+
+ @endif
+
+ @if ($isInherited)
+ Inherited
+ @elseif ($scope === 'workspace' && $workspaceId)
+ Override
+ @endif
+ @endif
+
+
+
+
+ @if ($editingKeyId === $key->id)
+
+ Save
+
+ Cancel
+
+ @else
+
+ @if (!$isLockedByParent)
+ Edit
+
+ @endif
+
+ @if ($scope === 'workspace' && $workspaceId && !$isInherited)
+
+ Clear
+
+ @endif
+
+ @endif
+
+
+ @endforeach
+
+
+
+ @if ($this->keys->isEmpty())
+
+ No configuration keys found.
+
+ @endif
+
diff --git a/src/Core/Config/View/Blade/admin/workspace-config.blade.php b/src/Core/Config/View/Blade/admin/workspace-config.blade.php
new file mode 100644
index 0000000..db5fa23
--- /dev/null
+++ b/src/Core/Config/View/Blade/admin/workspace-config.blade.php
@@ -0,0 +1,119 @@
+
+ {{-- Sidebar --}}
+
+ Settings
+
+
+
+
+ {{-- Main Content --}}
+
+ @if (!$this->workspace)
+
+
+ No Workspace
+ Select a workspace from the menu above.
+
+ @elseif ($path)
+
+
{{ ucfirst(str_replace('/', ' / ', $path)) }}
+
+ @if ($this->tabItems)
+
+ @endif
+
+ {{-- Settings list --}}
+
+ @forelse ($this->currentKeys as $key)
+
+
+
+ {{ $this->settingName($key->code) }}
+ @if ($key->description)
+ {{ $key->description }}
+ @endif
+
+
+ {{ $key->type->value }}
+ @if ($this->isLockedBySystem($key))
+ Locked
+ @elseif ($this->isInherited($key))
+ Inherited
+ @else
+ Custom
+ @endif
+
+
+
+ @if ($this->isLockedBySystem($key))
+
+ @php $val = $this->getValue($key); @endphp
+ @if (is_array($val))
+ {{ json_encode($val, JSON_PRETTY_PRINT) }}
+ @elseif (is_bool($val))
+ {{ $val ? 'true' : 'false' }}
+ @else
+ {{ $val ?? '-' }}
+ @endif
+
+ @else
+
+
+ @if ($key->type === \Core\Config\Enums\ConfigType::BOOL)
+
+ @elseif ($key->type === \Core\Config\Enums\ConfigType::INT)
+
+ @elseif ($key->type === \Core\Config\Enums\ConfigType::JSON || $key->type === \Core\Config\Enums\ConfigType::ARRAY)
+ {{ is_array($this->getValue($key)) ? json_encode($this->getValue($key), JSON_PRETTY_PRINT) : $this->getValue($key) }}
+ @else
+
+ @endif
+
+ @if (!$this->isInherited($key))
+
+ @endif
+
+
+ @if ($this->isInherited($key))
+ @php $inherited = $this->getInheritedValue($key); @endphp
+
+ Default: {{ is_array($inherited) ? json_encode($inherited) : ($inherited ?? 'none') }}
+
+ @endif
+ @endif
+
+ @empty
+
No settings in this group.
+ @endforelse
+
+
+ @else
+
+
+
+ Select a category from the sidebar
+
+
+ @endif
+
+
diff --git a/src/Core/Config/View/Modal/Admin/ConfigPanel.php b/src/Core/Config/View/Modal/Admin/ConfigPanel.php
new file mode 100644
index 0000000..579914b
--- /dev/null
+++ b/src/Core/Config/View/Modal/Admin/ConfigPanel.php
@@ -0,0 +1,279 @@
+ $categories
+ * @property-read \Illuminate\Database\Eloquent\Collection $workspaces
+ */
+class ConfigPanel extends Component
+{
+ #[Url]
+ public string $category = '';
+
+ #[Url]
+ public string $search = '';
+
+ #[Url]
+ public string $scope = 'system';
+
+ #[Url]
+ public ?int $workspaceId = null;
+
+ public ?int $editingKeyId = null;
+
+ public mixed $editValue = null;
+
+ public bool $editLocked = false;
+
+ protected ConfigService $config;
+
+ public function boot(ConfigService $config): void
+ {
+ $this->config = $config;
+ }
+
+ public function mount(): void
+ {
+ $this->checkHadesAccess();
+ }
+
+ private function checkHadesAccess(): void
+ {
+ if (! auth()->user()?->isHades()) {
+ abort(403, 'Hades access required');
+ }
+ }
+
+ #[Computed]
+ public function categories(): array
+ {
+ return ConfigKey::select('category')
+ ->distinct()
+ ->orderBy('category')
+ ->pluck('category')
+ ->toArray();
+ }
+
+ /**
+ * Get all workspaces (requires Tenant module).
+ */
+ #[Computed]
+ public function workspaces(): \Illuminate\Database\Eloquent\Collection
+ {
+ if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
+ return new \Illuminate\Database\Eloquent\Collection;
+ }
+
+ return \Core\Tenant\Models\Workspace::orderBy('name')->get();
+ }
+
+ #[Computed]
+ public function keys(): \Illuminate\Database\Eloquent\Collection
+ {
+ return ConfigKey::query()
+ ->when($this->category, fn ($q) => $q->where('category', $this->category))
+ ->when($this->search, fn ($q) => $q->where('code', 'LIKE', "%{$this->search}%"))
+ ->orderBy('category')
+ ->orderBy('code')
+ ->get();
+ }
+
+ #[Computed]
+ public function activeProfile(): ConfigProfile
+ {
+ if ($this->scope === 'workspace' && $this->workspaceId) {
+ $systemProfile = ConfigProfile::ensureSystem();
+
+ return ConfigProfile::ensureWorkspace($this->workspaceId, $systemProfile->id);
+ }
+
+ return ConfigProfile::ensureSystem();
+ }
+
+ /**
+ * Get selected workspace (requires Tenant module).
+ *
+ * @return object|null Workspace model instance or null
+ */
+ #[Computed]
+ public function selectedWorkspace(): ?object
+ {
+ if ($this->workspaceId && class_exists(\Core\Tenant\Models\Workspace::class)) {
+ return \Core\Tenant\Models\Workspace::find($this->workspaceId);
+ }
+
+ return null;
+ }
+
+ public function updatedScope(): void
+ {
+ if ($this->scope === 'system') {
+ $this->workspaceId = null;
+ }
+ $this->cancel();
+ }
+
+ public function updatedWorkspaceId(): void
+ {
+ $this->cancel();
+ }
+
+ public function getValue(ConfigKey $key): mixed
+ {
+ $value = ConfigValue::findValue($this->activeProfile->id, $key->id);
+
+ return $value?->getTypedValue() ?? $key->getTypedDefault();
+ }
+
+ public function getInheritedValue(ConfigKey $key): mixed
+ {
+ if ($this->scope !== 'workspace') {
+ return null;
+ }
+
+ $systemProfile = ConfigProfile::ensureSystem();
+ $value = ConfigValue::findValue($systemProfile->id, $key->id);
+
+ return $value?->getTypedValue();
+ }
+
+ public function isInherited(ConfigKey $key): bool
+ {
+ if ($this->scope !== 'workspace') {
+ return false;
+ }
+
+ $workspaceValue = ConfigValue::findValue($this->activeProfile->id, $key->id);
+
+ return $workspaceValue === null;
+ }
+
+ public function isLocked(ConfigKey $key): bool
+ {
+ $value = ConfigValue::findValue($this->activeProfile->id, $key->id);
+
+ return $value?->isLocked() ?? false;
+ }
+
+ public function isLockedByParent(ConfigKey $key): bool
+ {
+ if ($this->scope !== 'workspace') {
+ return false;
+ }
+
+ $systemProfile = ConfigProfile::ensureSystem();
+ $value = ConfigValue::findValue($systemProfile->id, $key->id);
+
+ return $value?->isLocked() ?? false;
+ }
+
+ public function edit(int $keyId): void
+ {
+ $key = ConfigKey::find($keyId);
+ if ($key === null) {
+ return;
+ }
+
+ $this->editingKeyId = $keyId;
+ $this->editValue = $this->getValue($key);
+ $this->editLocked = $this->isLocked($key);
+ }
+
+ public function save(): void
+ {
+ if ($this->editingKeyId === null) {
+ return;
+ }
+
+ $key = ConfigKey::find($this->editingKeyId);
+ if ($key === null) {
+ return;
+ }
+
+ // Check if parent has locked this key
+ if ($this->isLockedByParent($key)) {
+ $this->dispatch('config-error', message: 'This key is locked at system level');
+
+ return;
+ }
+
+ $this->config->set(
+ $key->code,
+ $this->editValue,
+ $this->activeProfile,
+ $this->editLocked,
+ );
+
+ $this->editingKeyId = null;
+ $this->editValue = null;
+ $this->editLocked = false;
+
+ $this->dispatch('config-saved');
+ }
+
+ public function cancel(): void
+ {
+ $this->editingKeyId = null;
+ $this->editValue = null;
+ $this->editLocked = false;
+ }
+
+ public function toggleLock(int $keyId): void
+ {
+ $key = ConfigKey::find($keyId);
+ if ($key === null) {
+ return;
+ }
+
+ if ($this->isLocked($key)) {
+ $this->config->unlock($key->code, $this->activeProfile);
+ } else {
+ $this->config->lock($key->code, $this->activeProfile);
+ }
+ }
+
+ public function clearOverride(int $keyId): void
+ {
+ if ($this->scope !== 'workspace') {
+ return;
+ }
+
+ $key = ConfigKey::find($keyId);
+ if ($key === null) {
+ return;
+ }
+
+ ConfigValue::where('profile_id', $this->activeProfile->id)
+ ->where('key_id', $key->id)
+ ->delete();
+
+ $this->dispatch('config-cleared');
+ }
+
+ public function render(): \Illuminate\Contracts\View\View
+ {
+ return view('core.config::admin.config-panel')
+ ->layout('hub::admin.layouts.app', ['title' => 'Configuration']);
+ }
+}
diff --git a/src/Core/Config/View/Modal/Admin/WorkspaceConfig.php b/src/Core/Config/View/Modal/Admin/WorkspaceConfig.php
new file mode 100644
index 0000000..04a569c
--- /dev/null
+++ b/src/Core/Config/View/Modal/Admin/WorkspaceConfig.php
@@ -0,0 +1,285 @@
+ $namespaces
+ * @property-read ConfigProfile|null $workspaceProfile
+ * @property-read ConfigProfile $systemProfile
+ * @property-read object|null $workspace
+ * @property-read string $prefix
+ * @property-read array $tabs
+ */
+class WorkspaceConfig extends Component
+{
+ public ?string $path = null;
+
+ protected ConfigService $config;
+
+ /**
+ * Workspace service instance (from Tenant module when available).
+ */
+ protected ?object $workspaceService = null;
+
+ public function boot(ConfigService $config): void
+ {
+ $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);
+ }
+ }
+
+ public function mount(?string $path = null): void
+ {
+ $this->path = $path;
+ }
+
+ #[On('workspace-changed')]
+ public function workspaceChanged(): void
+ {
+ unset($this->workspace);
+ unset($this->workspaceProfile);
+ }
+
+ public function navigate(string $path): void
+ {
+ $this->path = $path;
+ unset($this->prefix);
+ unset($this->depth);
+ unset($this->tabs);
+ unset($this->currentKeys);
+ }
+
+ /**
+ * Get current workspace (requires Tenant module).
+ *
+ * @return object|null Workspace model instance or null
+ */
+ #[Computed]
+ public function workspace(): ?object
+ {
+ return $this->workspaceService?->currentModel();
+ }
+
+ #[Computed]
+ public function prefix(): string
+ {
+ return $this->path ? str_replace('/', '.', $this->path) : '';
+ }
+
+ #[Computed]
+ public function depth(): int
+ {
+ return $this->path ? count(explode('/', $this->path)) : 0;
+ }
+
+ #[Computed]
+ public function namespaces(): array
+ {
+ return ConfigKey::orderBy('code')
+ ->get()
+ ->map(fn ($key) => explode('.', $key->code)[0])
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ #[Computed]
+ public function navItems(): array
+ {
+ return collect($this->namespaces)->map(fn ($ns) => [
+ 'label' => ucfirst($ns),
+ 'action' => "navigate('{$ns}')",
+ 'current' => str_starts_with($this->path ?? '', $ns),
+ ])->all();
+ }
+
+ #[Computed]
+ public function tabs(): array
+ {
+ if ($this->depth !== 1) {
+ return [];
+ }
+
+ $prefix = $this->prefix.'.';
+
+ return ConfigKey::where('code', 'like', $prefix.'%')
+ ->orderBy('code')
+ ->get()
+ ->filter(fn ($key) => count(explode('.', $key->code)) >= 3)
+ ->map(fn ($key) => explode('.', $key->code)[1])
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ #[Computed]
+ public function tabItems(): array
+ {
+ return collect($this->tabs)->map(fn ($t) => [
+ 'label' => ucfirst($t),
+ 'action' => "navigate('{$this->prefix}/{$t}')",
+ 'selected' => str_contains($this->path ?? '', '/'.$t),
+ ])->all();
+ }
+
+ #[Computed]
+ public function currentKeys(): array
+ {
+ if (! $this->path) {
+ return [];
+ }
+
+ $prefix = $this->prefix.'.';
+
+ $allKeys = ConfigKey::where('code', 'like', $prefix.'%')
+ ->orderBy('code')
+ ->pluck('code')
+ ->all();
+
+ // Direct children: prefix + one segment (no dots in remainder)
+ $matches = array_filter($allKeys, fn ($code) => ! str_contains(substr($code, strlen($prefix)), '.'));
+
+ return ConfigKey::whereIn('code', $matches)
+ ->orderBy('code')
+ ->get()
+ ->all();
+ }
+
+ #[Computed]
+ public function workspaceProfile(): ?ConfigProfile
+ {
+ if (! $this->workspace) {
+ return null;
+ }
+
+ $systemProfile = ConfigProfile::ensureSystem();
+
+ return ConfigProfile::ensureWorkspace($this->workspace->id, $systemProfile->id);
+ }
+
+ #[Computed]
+ public function systemProfile(): ConfigProfile
+ {
+ return ConfigProfile::ensureSystem();
+ }
+
+ public function settingName(string $code): string
+ {
+ $parts = explode('.', $code);
+
+ return end($parts) ?: $code;
+ }
+
+ public function getValue(ConfigKey $key): mixed
+ {
+ if (! $this->workspaceProfile) {
+ return $key->getTypedDefault();
+ }
+
+ $value = ConfigValue::findValue($this->workspaceProfile->id, $key->id);
+
+ if ($value !== null) {
+ return $value->getTypedValue();
+ }
+
+ $systemValue = ConfigValue::findValue($this->systemProfile->id, $key->id);
+
+ return $systemValue?->getTypedValue() ?? $key->getTypedDefault();
+ }
+
+ public function getInheritedValue(ConfigKey $key): mixed
+ {
+ $value = ConfigValue::findValue($this->systemProfile->id, $key->id);
+
+ return $value?->getTypedValue() ?? $key->getTypedDefault();
+ }
+
+ public function isInherited(ConfigKey $key): bool
+ {
+ if (! $this->workspaceProfile) {
+ return true;
+ }
+
+ $workspaceValue = ConfigValue::findValue($this->workspaceProfile->id, $key->id);
+
+ return $workspaceValue === null;
+ }
+
+ public function isLockedBySystem(ConfigKey $key): bool
+ {
+ $value = ConfigValue::findValue($this->systemProfile->id, $key->id);
+
+ return $value?->isLocked() ?? false;
+ }
+
+ public function toggleBool(int $keyId): void
+ {
+ $key = ConfigKey::find($keyId);
+ if (! $key || ! $this->workspaceProfile) {
+ return;
+ }
+
+ if ($this->isLockedBySystem($key)) {
+ return;
+ }
+
+ $currentValue = $this->getValue($key);
+ $this->config->set($key->code, ! $currentValue, $this->workspaceProfile, false);
+ }
+
+ public function updateValue(int $keyId, mixed $value): void
+ {
+ $key = ConfigKey::find($keyId);
+ if (! $key || ! $this->workspaceProfile) {
+ return;
+ }
+
+ if ($this->isLockedBySystem($key)) {
+ return;
+ }
+
+ $this->config->set($key->code, $value, $this->workspaceProfile, false);
+ }
+
+ public function clearOverride(int $keyId): void
+ {
+ if (! $this->workspaceProfile) {
+ return;
+ }
+
+ ConfigValue::where('profile_id', $this->workspaceProfile->id)
+ ->where('key_id', $keyId)
+ ->delete();
+
+ $this->dispatch('config-cleared');
+ }
+
+ public function render(): \Illuminate\Contracts\View\View
+ {
+ return view('core.config::admin.workspace-config')
+ ->layout('hub::admin.layouts.app', ['title' => 'Settings']);
+ }
+}
diff --git a/src/Core/Console/Boot.php b/src/Core/Console/Boot.php
new file mode 100644
index 0000000..7e7090c
--- /dev/null
+++ b/src/Core/Console/Boot.php
@@ -0,0 +1,34 @@
+ 'onConsole',
+ ];
+
+ public function onConsole(ConsoleBooting $event): void
+ {
+ $event->command(Commands\InstallCommand::class);
+ $event->command(Commands\NewProjectCommand::class);
+ $event->command(Commands\MakeModCommand::class);
+ $event->command(Commands\MakePlugCommand::class);
+ $event->command(Commands\MakeWebsiteCommand::class);
+ $event->command(Commands\PruneEmailShieldStatsCommand::class);
+ }
+}
diff --git a/src/Core/Console/Commands/InstallCommand.php b/src/Core/Console/Commands/InstallCommand.php
new file mode 100644
index 0000000..3fd375d
--- /dev/null
+++ b/src/Core/Console/Commands/InstallCommand.php
@@ -0,0 +1,493 @@
+
+ */
+ protected array $installationSteps = [
+ 'environment' => 'Setting up environment file',
+ 'application' => 'Configuring application settings',
+ 'migrations' => 'Running database migrations',
+ 'app_key' => 'Generating application key',
+ 'storage_link' => 'Creating storage symlink',
+ ];
+
+ /**
+ * Whether this is a dry run.
+ */
+ protected bool $isDryRun = false;
+
+ /**
+ * Track completed installation steps for rollback.
+ *
+ * @var array
+ */
+ protected array $completedSteps = [];
+
+ /**
+ * Original .env content for rollback.
+ */
+ protected ?string $originalEnvContent = null;
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $this->isDryRun = (bool) $this->option('dry-run');
+
+ $this->info('');
+ $this->info(' '.__('core::core.installer.title'));
+ $this->info(' '.str_repeat('=', strlen(__('core::core.installer.title'))));
+
+ if ($this->isDryRun) {
+ $this->warn(' [DRY RUN] No changes will be made');
+ }
+
+ $this->info('');
+
+ // Preserve original state for rollback (not needed in dry-run)
+ if (! $this->isDryRun) {
+ $this->preserveOriginalState();
+ }
+
+ try {
+ // Show progress bar for all steps
+ $this->info(' Installation Progress:');
+ $this->info('');
+
+ $steps = $this->getInstallationSteps();
+ $progressBar = $this->output->createProgressBar(count($steps));
+ $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
+ $progressBar->setMessage('Starting...');
+ $progressBar->start();
+
+ // Step 1: Environment file
+ $progressBar->setMessage($this->installationSteps['environment']);
+ if (! $this->setupEnvironment()) {
+ $progressBar->finish();
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+ $progressBar->advance();
+
+ // Step 2: Application settings
+ $progressBar->setMessage($this->installationSteps['application']);
+ $progressBar->display();
+ $this->newLine();
+ $this->configureApplication();
+ $progressBar->advance();
+
+ // Step 3: Database
+ $progressBar->setMessage($this->installationSteps['migrations']);
+ $progressBar->display();
+ if ($this->option('no-interaction') || $this->isDryRun || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) {
+ $this->runMigrations();
+ }
+ $progressBar->advance();
+
+ // Step 4: Generate app key if needed
+ $progressBar->setMessage($this->installationSteps['app_key']);
+ $this->generateAppKey();
+ $progressBar->advance();
+
+ // Step 5: Create storage link
+ $progressBar->setMessage($this->installationSteps['storage_link']);
+ $this->createStorageLink();
+ $progressBar->advance();
+
+ $progressBar->setMessage('Complete!');
+ $progressBar->finish();
+ $this->newLine(2);
+
+ // Done!
+ if ($this->isDryRun) {
+ $this->info(' [DRY RUN] Installation preview complete. No changes were made.');
+ } else {
+ $this->info(' '.__('core::core.installer.complete'));
+ }
+ $this->info('');
+ $this->info(' '.__('core::core.installer.next_steps').':');
+ $this->info(' 1. Run: valet link core');
+ $this->info(' 2. Visit: http://core.test');
+ $this->info('');
+
+ return self::SUCCESS;
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->error('');
+ $this->error(' Installation failed: '.$e->getMessage());
+ $this->error('');
+
+ if (! $this->isDryRun) {
+ $this->rollback();
+ }
+
+ return self::FAILURE;
+ }
+ }
+
+ /**
+ * Get the list of installation steps to execute.
+ *
+ * @return array
+ */
+ protected function getInstallationSteps(): array
+ {
+ return array_keys($this->installationSteps);
+ }
+
+ /**
+ * Log an action in dry-run mode or execute it.
+ */
+ protected function dryRunOrExecute(string $description, callable $action): mixed
+ {
+ if ($this->isDryRun) {
+ $this->info(" [WOULD] {$description}");
+
+ return null;
+ }
+
+ return $action();
+ }
+
+ /**
+ * Preserve original state for potential rollback.
+ */
+ protected function preserveOriginalState(): void
+ {
+ $envPath = base_path('.env');
+ if (File::exists($envPath)) {
+ $this->originalEnvContent = File::get($envPath);
+ }
+ }
+
+ /**
+ * Rollback changes on installation failure.
+ */
+ protected function rollback(): void
+ {
+ $this->warn(' Rolling back changes...');
+
+ // Restore original .env if we modified it
+ if (isset($this->completedSteps['env_created']) && $this->completedSteps['env_created']) {
+ $envPath = base_path('.env');
+ if ($this->originalEnvContent !== null) {
+ File::put($envPath, $this->originalEnvContent);
+ $this->info(' [✓] Restored original .env file');
+ } else {
+ File::delete($envPath);
+ $this->info(' [✓] Removed created .env file');
+ }
+ }
+
+ // Restore original .env content if we only modified values
+ if (isset($this->completedSteps['env_modified']) && $this->completedSteps['env_modified'] && $this->originalEnvContent !== null) {
+ File::put(base_path('.env'), $this->originalEnvContent);
+ $this->info(' [✓] Restored original .env configuration');
+ }
+
+ // Remove storage link if we created it
+ if (isset($this->completedSteps['storage_link']) && $this->completedSteps['storage_link']) {
+ $publicStorage = public_path('storage');
+ if (File::exists($publicStorage) && is_link($publicStorage)) {
+ File::delete($publicStorage);
+ $this->info(' [✓] Removed storage symlink');
+ }
+ }
+
+ // Remove SQLite file if we created it
+ if (isset($this->completedSteps['sqlite_created']) && $this->completedSteps['sqlite_created']) {
+ $sqlitePath = database_path('database.sqlite');
+ if (File::exists($sqlitePath)) {
+ File::delete($sqlitePath);
+ $this->info(' [✓] Removed SQLite database file');
+ }
+ }
+
+ $this->info(' Rollback complete.');
+ }
+
+ /**
+ * Set up the .env file.
+ */
+ protected function setupEnvironment(): bool
+ {
+ $envPath = base_path('.env');
+ $envExamplePath = base_path('.env.example');
+
+ if (File::exists($envPath) && ! $this->option('force')) {
+ $this->info(' [✓] '.__('core::core.installer.env_exists'));
+
+ return true;
+ }
+
+ if (! File::exists($envExamplePath)) {
+ $this->error(' [✗] '.__('core::core.installer.env_missing'));
+
+ return false;
+ }
+
+ if ($this->isDryRun) {
+ $this->info(' [WOULD] Copy .env.example to .env');
+ } else {
+ File::copy($envExamplePath, $envPath);
+ $this->completedSteps['env_created'] = true;
+ }
+ $this->info(' [✓] '.__('core::core.installer.env_created'));
+
+ return true;
+ }
+
+ /**
+ * Configure application settings.
+ */
+ protected function configureApplication(): void
+ {
+ if ($this->option('no-interaction')) {
+ $this->info(' [✓] '.__('core::core.installer.default_config'));
+
+ return;
+ }
+
+ if ($this->isDryRun) {
+ $this->info(' [WOULD] Prompt for app name, domain, and database settings');
+ $this->info(' [WOULD] Update .env with configured values');
+ $this->info(' [✓] '.__('core::core.installer.default_config').' (dry-run)');
+
+ return;
+ }
+
+ // App name
+ $appName = $this->ask(__('core::core.installer.prompts.app_name'), __('core::core.brand.name'));
+ $this->updateEnv('APP_BRAND_NAME', $appName);
+
+ // Domain
+ $domain = $this->ask(__('core::core.installer.prompts.domain'), 'core.test');
+ $this->updateEnv('APP_DOMAIN', $domain);
+ $this->updateEnv('APP_URL', "http://{$domain}");
+
+ // Database
+ $this->info('');
+ $this->info(' Database Configuration:');
+ $dbConnection = $this->choice(__('core::core.installer.prompts.db_driver'), ['sqlite', 'mysql', 'pgsql'], 0);
+
+ if ($dbConnection === 'sqlite') {
+ $this->updateEnv('DB_CONNECTION', 'sqlite');
+ $this->updateEnv('DB_DATABASE', 'database/database.sqlite');
+
+ // Create SQLite file
+ $sqlitePath = database_path('database.sqlite');
+ if (! File::exists($sqlitePath)) {
+ File::put($sqlitePath, '');
+ $this->completedSteps['sqlite_created'] = true;
+ $this->info(' [✓] Created SQLite database');
+ }
+ } else {
+ $this->updateEnv('DB_CONNECTION', $dbConnection);
+ $dbHost = $this->ask(__('core::core.installer.prompts.db_host'), '127.0.0.1');
+ $dbPort = $this->ask(__('core::core.installer.prompts.db_port'), $dbConnection === 'mysql' ? '3306' : '5432');
+ $dbName = $this->ask(__('core::core.installer.prompts.db_name'), 'core');
+ $dbUser = $this->ask(__('core::core.installer.prompts.db_user'), 'root');
+ $dbPass = $this->secret(__('core::core.installer.prompts.db_password'));
+
+ $this->updateEnv('DB_HOST', $dbHost);
+ $this->updateEnv('DB_PORT', $dbPort);
+ $this->updateEnv('DB_DATABASE', $dbName);
+ $this->updateEnv('DB_USERNAME', $dbUser);
+ $this->updateEnv('DB_PASSWORD', $dbPass ?? '');
+
+ // Display masked confirmation (never show actual credentials)
+ $this->info('');
+ $this->info(' Database settings configured:');
+ $this->info(" Driver: {$dbConnection}");
+ $this->info(" Host: {$dbHost}");
+ $this->info(" Port: {$dbPort}");
+ $this->info(" Database: {$dbName}");
+ $this->info(' Username: '.$this->maskValue($dbUser));
+ $this->info(' Password: '.$this->maskValue($dbPass ?? '', true));
+ }
+
+ $this->completedSteps['env_modified'] = true;
+ $this->info(' [✓] '.__('core::core.installer.config_saved'));
+ }
+
+ /**
+ * Mask a sensitive value for display.
+ */
+ protected function maskValue(string $value, bool $isPassword = false): string
+ {
+ if ($value === '') {
+ return $isPassword ? '[not set]' : '[empty]';
+ }
+
+ if ($isPassword) {
+ return str_repeat('*', min(strlen($value), 8));
+ }
+
+ $length = strlen($value);
+ if ($length <= 2) {
+ return str_repeat('*', $length);
+ }
+
+ // Show first and last character with asterisks in between
+ return $value[0].str_repeat('*', $length - 2).$value[$length - 1];
+ }
+
+ /**
+ * Run database migrations.
+ */
+ protected function runMigrations(): void
+ {
+ $this->info('');
+
+ if ($this->isDryRun) {
+ $this->info(' [WOULD] Run: php artisan migrate --force');
+ $this->info(' [✓] '.__('core::core.installer.migrations_complete').' (dry-run)');
+
+ return;
+ }
+
+ $this->info(' Running migrations...');
+
+ $this->call('migrate', ['--force' => true]);
+
+ $this->info(' [✓] '.__('core::core.installer.migrations_complete'));
+ }
+
+ /**
+ * Generate application key if not set.
+ */
+ protected function generateAppKey(): void
+ {
+ $key = config('app.key');
+
+ if (empty($key) || $key === 'base64:') {
+ if ($this->isDryRun) {
+ $this->info(' [WOULD] Run: php artisan key:generate');
+ $this->info(' [✓] '.__('core::core.installer.key_generated').' (dry-run)');
+ } else {
+ $this->call('key:generate');
+ $this->info(' [✓] '.__('core::core.installer.key_generated'));
+ }
+ } else {
+ $this->info(' [✓] '.__('core::core.installer.key_exists'));
+ }
+ }
+
+ /**
+ * Create storage symlink.
+ */
+ protected function createStorageLink(): void
+ {
+ $publicStorage = public_path('storage');
+
+ if (File::exists($publicStorage)) {
+ $this->info(' [✓] '.__('core::core.installer.storage_link_exists'));
+
+ return;
+ }
+
+ if ($this->isDryRun) {
+ $this->info(' [WOULD] Run: php artisan storage:link');
+ $this->info(' [✓] '.__('core::core.installer.storage_link_created').' (dry-run)');
+
+ return;
+ }
+
+ $this->call('storage:link');
+ $this->completedSteps['storage_link'] = true;
+ $this->info(' [✓] '.__('core::core.installer.storage_link_created'));
+ }
+
+ /**
+ * Update a value in the .env file.
+ */
+ protected function updateEnv(string $key, string $value): void
+ {
+ $envPath = base_path('.env');
+
+ if (! File::exists($envPath)) {
+ return;
+ }
+
+ $content = File::get($envPath);
+
+ // Quote value if it contains spaces
+ if (str_contains($value, ' ')) {
+ $value = "\"{$value}\"";
+ }
+
+ // Check if key exists (escape regex special chars in key)
+ $escapedKey = preg_quote($key, '/');
+ if (preg_match("/^{$escapedKey}=/m", $content)) {
+ // Update existing key
+ $content = preg_replace(
+ "/^{$escapedKey}=.*/m",
+ "{$key}={$value}",
+ $content
+ );
+ } else {
+ // Add new key
+ $content .= "\n{$key}={$value}";
+ }
+
+ File::put($envPath, $content);
+ }
+
+ /**
+ * Get shell completion suggestions for options.
+ *
+ * This command has no option values that need completion hints,
+ * but implements the method for consistency with other commands.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ // No argument/option values need completion for this command
+ // All options are flags (--force, --no-interaction, --dry-run)
+ }
+}
diff --git a/src/Core/Console/Commands/MakeModCommand.php b/src/Core/Console/Commands/MakeModCommand.php
new file mode 100644
index 0000000..a069743
--- /dev/null
+++ b/src/Core/Console/Commands/MakeModCommand.php
@@ -0,0 +1,527 @@
+
+ */
+ protected array $createdFiles = [];
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $name = Str::studly($this->argument('name'));
+ $modulePath = $this->getModulePath($name);
+
+ if (File::isDirectory($modulePath) && ! $this->option('force')) {
+ $this->newLine();
+ $this->components->error("Module [{$name}] already exists!");
+ $this->newLine();
+ $this->components->warn('Use --force to overwrite the existing module.');
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+
+ $this->newLine();
+ $this->components->info("Creating module: {$name} ");
+ $this->newLine();
+
+ // Create directory structure
+ $this->createDirectoryStructure($modulePath);
+
+ // Create Boot.php
+ $this->createBootFile($modulePath, $name);
+
+ // Create optional route files based on flags
+ $this->createOptionalFiles($modulePath, $name);
+
+ // Show summary table of created files
+ $this->newLine();
+ $this->components->twoColumnDetail('Created Files>', 'Description>');
+ foreach ($this->createdFiles as $file) {
+ $this->components->twoColumnDetail(
+ "{$file['file']}>",
+ "{$file['description']}>"
+ );
+ }
+
+ $this->newLine();
+ $this->components->info("Module [{$name}] created successfully!");
+ $this->newLine();
+ $this->components->twoColumnDetail('Location', "{$modulePath}>");
+ $this->newLine();
+
+ $this->components->info('Next steps:');
+ $this->line(' 1.> Add your module logic to the Boot.php event handlers');
+ $this->line(' 2.> Create Models, Views, and Controllers as needed');
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get the path for the module.
+ */
+ protected function getModulePath(string $name): string
+ {
+ // Check for packages structure first (monorepo)
+ $packagesPath = base_path("packages/core-php/src/Mod/{$name}");
+ if (File::isDirectory(dirname($packagesPath))) {
+ return $packagesPath;
+ }
+
+ // Fall back to app/Mod for consuming applications
+ return base_path("app/Mod/{$name}");
+ }
+
+ /**
+ * Create the directory structure for the module.
+ */
+ protected function createDirectoryStructure(string $modulePath): void
+ {
+ $directories = [
+ $modulePath,
+ "{$modulePath}/Models",
+ "{$modulePath}/View",
+ "{$modulePath}/View/Blade",
+ ];
+
+ if ($this->hasRoutes()) {
+ $directories[] = "{$modulePath}/Routes";
+ }
+
+ if ($this->option('console') || $this->option('all')) {
+ $directories[] = "{$modulePath}/Console";
+ $directories[] = "{$modulePath}/Console/Commands";
+ }
+
+ foreach ($directories as $directory) {
+ File::ensureDirectoryExists($directory);
+ }
+
+ $this->components->task('Creating directory structure', fn () => true);
+ }
+
+ /**
+ * Check if any route handlers are requested.
+ */
+ protected function hasRoutes(): bool
+ {
+ return $this->option('web')
+ || $this->option('admin')
+ || $this->option('api')
+ || $this->option('all');
+ }
+
+ /**
+ * Create the Boot.php file.
+ */
+ protected function createBootFile(string $modulePath, string $name): void
+ {
+ $namespace = $this->resolveNamespace($modulePath, $name);
+ $listeners = $this->buildListenersArray();
+ $handlers = $this->buildHandlerMethods($name);
+
+ $content = <<buildUseStatements()}
+
+/**
+ * {$name} Module - Event-driven module registration.
+ *
+ * This module uses the lazy loading pattern where handlers
+ * are only invoked when their corresponding events fire.
+ */
+class Boot
+{
+ /**
+ * Events this module listens to for lazy loading.
+ *
+ * @var array
+ */
+ public static array \$listens = [
+{$listeners}
+ ];
+{$handlers}
+}
+
+PHP;
+
+ File::put("{$modulePath}/Boot.php", $content);
+ $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Event-driven module loader'];
+ $this->components->task('Creating Boot.php', fn () => true);
+ }
+
+ /**
+ * Resolve the namespace for the module.
+ */
+ protected function resolveNamespace(string $modulePath, string $name): string
+ {
+ if (str_contains($modulePath, 'packages/core-php/src/Mod')) {
+ return "Core\\Mod\\{$name}";
+ }
+
+ return "Mod\\{$name}";
+ }
+
+ /**
+ * Build the use statements for the Boot file.
+ */
+ protected function buildUseStatements(): string
+ {
+ $statements = [];
+
+ if ($this->option('web') || $this->option('all')) {
+ $statements[] = 'use Core\Events\WebRoutesRegistering;';
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $statements[] = 'use Core\Events\AdminPanelBooting;';
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $statements[] = 'use Core\Events\ApiRoutesRegistering;';
+ }
+
+ if ($this->option('console') || $this->option('all')) {
+ $statements[] = 'use Core\Events\ConsoleBooting;';
+ }
+
+ if (empty($statements)) {
+ $statements[] = 'use Core\Events\WebRoutesRegistering;';
+ }
+
+ return implode("\n", $statements);
+ }
+
+ /**
+ * Build the listeners array content.
+ */
+ protected function buildListenersArray(): string
+ {
+ $listeners = [];
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',";
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $listeners[] = " AdminPanelBooting::class => 'onAdminPanel',";
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',";
+ }
+
+ if ($this->option('console') || $this->option('all')) {
+ $listeners[] = " ConsoleBooting::class => 'onConsole',";
+ }
+
+ return implode("\n", $listeners);
+ }
+
+ /**
+ * Check if any specific option was provided.
+ */
+ protected function hasAnyOption(): bool
+ {
+ return $this->option('web')
+ || $this->option('admin')
+ || $this->option('api')
+ || $this->option('console')
+ || $this->option('all');
+ }
+
+ /**
+ * Build the handler methods.
+ */
+ protected function buildHandlerMethods(string $name): string
+ {
+ $methods = [];
+ $moduleName = Str::snake($name);
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $methods[] = <<views('{$moduleName}', __DIR__.'/View/Blade');
+
+ if (file_exists(__DIR__.'/Routes/web.php')) {
+ \$event->routes(fn () => require __DIR__.'/Routes/web.php');
+ }
+ }
+PHP;
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $methods[] = <<livewire('{$moduleName}.admin.index', View\Modal\Admin\Index::class);
+
+ if (file_exists(__DIR__.'/Routes/admin.php')) {
+ \$event->routes(fn () => require __DIR__.'/Routes/admin.php');
+ }
+ }
+PHP;
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $methods[] = <<<'PHP'
+
+ /**
+ * Register API routes.
+ */
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ if (file_exists(__DIR__.'/Routes/api.php')) {
+ $event->routes(fn () => require __DIR__.'/Routes/api.php');
+ }
+ }
+PHP;
+ }
+
+ if ($this->option('console') || $this->option('all')) {
+ $methods[] = <<command(Console\Commands\ExampleCommand::class);
+ }
+PHP;
+ }
+
+ return implode("\n", $methods);
+ }
+
+ /**
+ * Create optional files based on flags.
+ */
+ protected function createOptionalFiles(string $modulePath, string $name): void
+ {
+ $moduleName = Str::snake($name);
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $this->createWebRoutes($modulePath, $moduleName);
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $this->createAdminRoutes($modulePath, $moduleName);
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $this->createApiRoutes($modulePath, $moduleName);
+ }
+
+ // Create a sample view
+ $this->createSampleView($modulePath, $moduleName);
+ }
+
+ /**
+ * Create web routes file.
+ */
+ protected function createWebRoutes(string $modulePath, string $moduleName): void
+ {
+ $content = <<group(function () {
+ Route::get('/', function () {
+ return view('{$moduleName}::index');
+ })->name('{$moduleName}.index');
+});
+
+PHP;
+
+ File::put("{$modulePath}/Routes/web.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes'];
+ $this->components->task('Creating Routes/web.php', fn () => true);
+ }
+
+ /**
+ * Create admin routes file.
+ */
+ protected function createAdminRoutes(string $modulePath, string $moduleName): void
+ {
+ $content = <<name('{$moduleName}.admin.')->group(function () {
+ Route::get('/', function () {
+ return view('{$moduleName}::admin.index');
+ })->name('index');
+});
+
+PHP;
+
+ File::put("{$modulePath}/Routes/admin.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes'];
+ $this->components->task('Creating Routes/admin.php', fn () => true);
+ }
+
+ /**
+ * Create API routes file.
+ */
+ protected function createApiRoutes(string $modulePath, string $moduleName): void
+ {
+ $content = <<name('api.{$moduleName}.')->group(function () {
+ Route::get('/', function () {
+ return response()->json(['module' => '{$moduleName}', 'status' => 'ok']);
+ })->name('index');
+});
+
+PHP;
+
+ File::put("{$modulePath}/Routes/api.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes'];
+ $this->components->task('Creating Routes/api.php', fn () => true);
+ }
+
+ /**
+ * Create a sample view file.
+ */
+ protected function createSampleView(string $modulePath, string $moduleName): void
+ {
+ $content = <<
+ {$moduleName}
+
+
+
{$moduleName} Module
+
Welcome to the {$moduleName} module.
+
+
+
+BLADE;
+
+ File::put("{$modulePath}/View/Blade/index.blade.php", $content);
+ $this->createdFiles[] = ['file' => 'View/Blade/index.blade.php', 'description' => 'Sample index view'];
+ $this->components->task('Creating View/Blade/index.blade.php', fn () => true);
+ }
+
+ /**
+ * Get shell completion suggestions for arguments.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ if ($input->mustSuggestArgumentValuesFor('name')) {
+ // Suggest common module naming patterns
+ $suggestions->suggestValues([
+ 'Auth',
+ 'Blog',
+ 'Content',
+ 'Dashboard',
+ 'Media',
+ 'Settings',
+ 'Users',
+ ]);
+ }
+ }
+}
diff --git a/src/Core/Console/Commands/MakePlugCommand.php b/src/Core/Console/Commands/MakePlugCommand.php
new file mode 100644
index 0000000..0d187f5
--- /dev/null
+++ b/src/Core/Console/Commands/MakePlugCommand.php
@@ -0,0 +1,629 @@
+
+ */
+ protected array $createdOperations = [];
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $name = Str::studly($this->argument('name'));
+ $category = Str::studly($this->option('category'));
+
+ if (! in_array($category, self::CATEGORIES)) {
+ $this->newLine();
+ $this->components->error("Invalid category [{$category}].");
+ $this->newLine();
+ $this->components->bulletList(self::CATEGORIES);
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+
+ $providerPath = $this->getProviderPath($category, $name);
+
+ if (File::isDirectory($providerPath) && ! $this->option('force')) {
+ $this->newLine();
+ $this->components->error("Provider [{$name}] already exists in [{$category}]!");
+ $this->newLine();
+ $this->components->warn('Use --force to overwrite the existing provider.');
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+
+ $this->newLine();
+ $this->components->info("Creating Plug provider: {$category}/{$name} ");
+ $this->newLine();
+
+ // Create directory structure
+ File::ensureDirectoryExists($providerPath);
+ $this->components->task('Creating provider directory', fn () => true);
+
+ // Create operations based on flags
+ $this->createOperations($providerPath, $category, $name);
+
+ // Show summary table of created operations
+ $this->newLine();
+ $this->components->twoColumnDetail('Created Operations>', 'Description>');
+ foreach ($this->createdOperations as $op) {
+ $this->components->twoColumnDetail(
+ "{$op['operation']}>",
+ "{$op['description']}>"
+ );
+ }
+
+ $this->newLine();
+ $this->components->info("Plug provider [{$category}/{$name}] created successfully!");
+ $this->newLine();
+ $this->components->twoColumnDetail('Location', "{$providerPath}>");
+ $this->newLine();
+
+ $this->components->info('Usage example:');
+ $this->line(" use> Plug\\{$category}\\{$name}\\Auth;");
+ $this->newLine();
+ $this->line(' $auth> = new> Auth(\$clientId>, \$clientSecret>, \$redirectUrl>);');
+ $this->line(' $authUrl> = \$auth>->getAuthUrl>();');
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get the path for the provider.
+ */
+ protected function getProviderPath(string $category, string $name): string
+ {
+ // Check for packages structure first (monorepo)
+ $packagesPath = base_path("packages/core-php/src/Plug/{$category}/{$name}");
+ if (File::isDirectory(dirname(dirname($packagesPath)))) {
+ return $packagesPath;
+ }
+
+ // Fall back to app/Plug for consuming applications
+ return base_path("app/Plug/{$category}/{$name}");
+ }
+
+ /**
+ * Resolve the namespace for the provider.
+ */
+ protected function resolveNamespace(string $providerPath, string $category, string $name): string
+ {
+ if (str_contains($providerPath, 'packages/core-php/src/Plug')) {
+ return "Core\\Plug\\{$category}\\{$name}";
+ }
+
+ return "Plug\\{$category}\\{$name}";
+ }
+
+ /**
+ * Create operations based on flags.
+ */
+ protected function createOperations(string $providerPath, string $category, string $name): void
+ {
+ $namespace = $this->resolveNamespace($providerPath, $category, $name);
+
+ // Always create Auth if --auth or --all or no specific options
+ if ($this->option('auth') || $this->option('all') || ! $this->hasAnyOperation()) {
+ $this->createAuthOperation($providerPath, $namespace, $name);
+ }
+
+ if ($this->option('post') || $this->option('all')) {
+ $this->createPostOperation($providerPath, $namespace, $name);
+ }
+
+ if ($this->option('delete') || $this->option('all')) {
+ $this->createDeleteOperation($providerPath, $namespace, $name);
+ }
+
+ if ($this->option('media') || $this->option('all')) {
+ $this->createMediaOperation($providerPath, $namespace, $name);
+ }
+ }
+
+ /**
+ * Check if any operation option was provided.
+ */
+ protected function hasAnyOperation(): bool
+ {
+ return $this->option('auth')
+ || $this->option('post')
+ || $this->option('delete')
+ || $this->option('media')
+ || $this->option('all');
+ }
+
+ /**
+ * Create the Auth operation.
+ */
+ protected function createAuthOperation(string $providerPath, string $namespace, string $name): void
+ {
+ $content = <<clientId = \$clientId;
+ \$this->clientSecret = \$clientSecret;
+ \$this->redirectUrl = \$redirectUrl;
+ }
+
+ /**
+ * Get the provider display name.
+ */
+ public static function name(): string
+ {
+ return '{$name}';
+ }
+
+ /**
+ * Set OAuth scopes.
+ *
+ * @param string[] \$scopes
+ */
+ public function withScopes(array \$scopes): static
+ {
+ \$this->scopes = \$scopes;
+
+ return \$this;
+ }
+
+ /**
+ * Get the authorization URL for user redirect.
+ */
+ public function getAuthUrl(?string \$state = null): string
+ {
+ \$params = [
+ 'client_id' => \$this->clientId,
+ 'redirect_uri' => \$this->redirectUrl,
+ 'response_type' => 'code',
+ 'scope' => implode(' ', \$this->scopes),
+ ];
+
+ if (\$state) {
+ \$params['state'] = \$state;
+ }
+
+ // TODO: [USER] Replace with your provider's OAuth authorization URL
+ // Example: return 'https://api.twitter.com/oauth/authorize?' . http_build_query(\$params);
+ return 'https://example.com/oauth/authorize?'.http_build_query(\$params);
+ }
+
+ /**
+ * Exchange authorization code for access token.
+ */
+ public function exchangeCode(string \$code): Response
+ {
+ // TODO: [USER] Implement token exchange with your provider's API
+ // Make a POST request to the provider's token endpoint with the authorization code
+ return \$this->ok([
+ 'access_token' => '',
+ 'refresh_token' => '',
+ 'expires_in' => 0,
+ ]);
+ }
+
+ /**
+ * Refresh an expired access token.
+ */
+ public function refreshToken(string \$refreshToken): Response
+ {
+ // TODO: [USER] Implement token refresh with your provider's API
+ // Use the refresh token to obtain a new access token
+ return \$this->ok([
+ 'access_token' => '',
+ 'refresh_token' => '',
+ 'expires_in' => 0,
+ ]);
+ }
+
+ /**
+ * Revoke an access token.
+ */
+ public function revokeToken(string \$accessToken): Response
+ {
+ // TODO: [USER] Implement token revocation with your provider's API
+ // Call the provider's revocation endpoint to invalidate the token
+ return \$this->ok(['revoked' => true]);
+ }
+
+ /**
+ * Get an HTTP client instance.
+ */
+ protected function http(): PendingRequest
+ {
+ return Http::acceptJson()
+ ->timeout(30);
+ }
+}
+
+PHP;
+
+ File::put("{$providerPath}/Auth.php", $content);
+ $this->createdOperations[] = ['operation' => 'Auth.php', 'description' => 'OAuth 2.0 authentication'];
+ $this->components->task('Creating Auth.php', fn () => true);
+ }
+
+ /**
+ * Create the Post operation.
+ */
+ protected function createPostOperation(string $providerPath, string $namespace, string $name): void
+ {
+ $content = <<http()
+ // ->withToken(\$this->accessToken())
+ // ->post('https://api.example.com/posts', [
+ // 'text' => \$content,
+ // ...\$options,
+ // ]);
+ //
+ // return \$this->fromResponse(\$response);
+
+ return \$this->ok([
+ 'id' => '',
+ 'url' => '',
+ 'created_at' => now()->toIso8601String(),
+ ]);
+ }
+
+ /**
+ * Schedule a post for later.
+ */
+ public function schedule(string \$content, \DateTimeInterface \$publishAt, array \$options = []): Response
+ {
+ // TODO: [USER] Implement scheduled posting with your provider's API
+ return \$this->ok([
+ 'id' => '',
+ 'scheduled_at' => \$publishAt->format('c'),
+ ]);
+ }
+
+ /**
+ * Get an HTTP client instance.
+ */
+ protected function http(): PendingRequest
+ {
+ return Http::acceptJson()
+ ->timeout(30);
+ }
+}
+
+PHP;
+
+ File::put("{$providerPath}/Post.php", $content);
+ $this->createdOperations[] = ['operation' => 'Post.php', 'description' => 'Content creation/publishing'];
+ $this->components->task('Creating Post.php', fn () => true);
+ }
+
+ /**
+ * Create the Delete operation.
+ */
+ protected function createDeleteOperation(string $providerPath, string $namespace, string $name): void
+ {
+ $content = <<http()
+ // ->withToken(\$this->accessToken())
+ // ->delete("https://api.example.com/posts/{\$postId}");
+ //
+ // return \$this->fromResponse(\$response);
+
+ return \$this->ok(['deleted' => true]);
+ }
+
+ /**
+ * Get an HTTP client instance.
+ */
+ protected function http(): PendingRequest
+ {
+ return Http::acceptJson()
+ ->timeout(30);
+ }
+}
+
+PHP;
+
+ File::put("{$providerPath}/Delete.php", $content);
+ $this->createdOperations[] = ['operation' => 'Delete.php', 'description' => 'Content deletion'];
+ $this->components->task('Creating Delete.php', fn () => true);
+ }
+
+ /**
+ * Create the Media operation.
+ */
+ protected function createMediaOperation(string $providerPath, string $namespace, string $name): void
+ {
+ $content = <<http()
+ // ->withToken(\$this->accessToken())
+ // ->attach('media', file_get_contents(\$filePath), basename(\$filePath))
+ // ->post('https://api.example.com/media/upload', \$options);
+ //
+ // return \$this->fromResponse(\$response);
+
+ return \$this->ok([
+ 'media_id' => '',
+ 'url' => '',
+ ]);
+ }
+
+ /**
+ * Upload media from a URL.
+ */
+ public function uploadFromUrl(string \$url, array \$options = []): Response
+ {
+ // TODO: [USER] Implement URL-based media upload with your provider's API
+ return \$this->ok([
+ 'media_id' => '',
+ 'url' => '',
+ ]);
+ }
+
+ /**
+ * Get an HTTP client instance.
+ */
+ protected function http(): PendingRequest
+ {
+ return Http::acceptJson()
+ ->timeout(60); // Longer timeout for uploads
+ }
+}
+
+PHP;
+
+ File::put("{$providerPath}/Media.php", $content);
+ $this->createdOperations[] = ['operation' => 'Media.php', 'description' => 'Media file uploads'];
+ $this->components->task('Creating Media.php', fn () => true);
+ }
+
+ /**
+ * Get shell completion suggestions for arguments and options.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ if ($input->mustSuggestArgumentValuesFor('name')) {
+ // Suggest common social platform names
+ $suggestions->suggestValues([
+ 'Twitter',
+ 'Instagram',
+ 'Facebook',
+ 'LinkedIn',
+ 'TikTok',
+ 'YouTube',
+ 'Mastodon',
+ 'Threads',
+ 'Bluesky',
+ ]);
+ }
+
+ if ($input->mustSuggestOptionValuesFor('category')) {
+ $suggestions->suggestValues(self::CATEGORIES);
+ }
+ }
+}
diff --git a/src/Core/Console/Commands/MakeWebsiteCommand.php b/src/Core/Console/Commands/MakeWebsiteCommand.php
new file mode 100644
index 0000000..ef852c3
--- /dev/null
+++ b/src/Core/Console/Commands/MakeWebsiteCommand.php
@@ -0,0 +1,603 @@
+
+ */
+ protected array $createdFiles = [];
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $name = Str::studly($this->argument('name'));
+ $domain = $this->option('domain') ?: Str::snake($name, '-').'.test';
+ $websitePath = $this->getWebsitePath($name);
+
+ if (File::isDirectory($websitePath) && ! $this->option('force')) {
+ $this->newLine();
+ $this->components->error("Website [{$name}] already exists!");
+ $this->newLine();
+ $this->components->warn('Use --force to overwrite the existing website.');
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+
+ $this->newLine();
+ $this->components->info("Creating website: {$name} ");
+ $this->components->twoColumnDetail('Domain', "{$domain}>");
+ $this->newLine();
+
+ // Create directory structure
+ $this->createDirectoryStructure($websitePath);
+
+ // Create Boot.php
+ $this->createBootFile($websitePath, $name, $domain);
+
+ // Create optional route files
+ $this->createOptionalFiles($websitePath, $name);
+
+ // Show summary table of created files
+ $this->newLine();
+ $this->components->twoColumnDetail('Created Files>', 'Description>');
+ foreach ($this->createdFiles as $file) {
+ $this->components->twoColumnDetail(
+ "{$file['file']}>",
+ "{$file['description']}>"
+ );
+ }
+
+ $this->newLine();
+ $this->components->info("Website [{$name}] created successfully!");
+ $this->newLine();
+ $this->components->twoColumnDetail('Location', "{$websitePath}>");
+ $this->newLine();
+
+ $this->components->info('Next steps:');
+ $this->line(" 1.> Configure your local dev server to serve {$domain}>");
+ $this->line(' (e.g.,> valet link '.Str::snake($name, '-').')>');
+ $this->line(" 2.> Visit http://{$domain}> to see your website");
+ $this->line(' 3.> Add routes, views, and controllers as needed');
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get the path for the website.
+ */
+ protected function getWebsitePath(string $name): string
+ {
+ // Websites go in app/Website for consuming applications
+ return base_path("app/Website/{$name}");
+ }
+
+ /**
+ * Create the directory structure for the website.
+ */
+ protected function createDirectoryStructure(string $websitePath): void
+ {
+ $directories = [
+ $websitePath,
+ "{$websitePath}/View",
+ "{$websitePath}/View/Blade",
+ "{$websitePath}/View/Blade/layouts",
+ ];
+
+ if ($this->hasRoutes()) {
+ $directories[] = "{$websitePath}/Routes";
+ }
+
+ foreach ($directories as $directory) {
+ File::ensureDirectoryExists($directory);
+ }
+
+ $this->components->task('Creating directory structure', fn () => true);
+ }
+
+ /**
+ * Check if any route handlers are requested.
+ */
+ protected function hasRoutes(): bool
+ {
+ return $this->option('web')
+ || $this->option('admin')
+ || $this->option('api')
+ || $this->option('all')
+ || ! $this->hasAnyOption();
+ }
+
+ /**
+ * Check if any specific option was provided.
+ */
+ protected function hasAnyOption(): bool
+ {
+ return $this->option('web')
+ || $this->option('admin')
+ || $this->option('api')
+ || $this->option('all');
+ }
+
+ /**
+ * Create the Boot.php file.
+ */
+ protected function createBootFile(string $websitePath, string $name, string $domain): void
+ {
+ $namespace = "Website\\{$name}";
+ $domainPattern = $this->buildDomainPattern($domain);
+ $listeners = $this->buildListenersArray();
+ $handlers = $this->buildHandlerMethods($name);
+
+ $content = <<buildUseStatements()}
+use Illuminate\Support\ServiceProvider;
+
+/**
+ * {$name} Website - Domain-isolated website provider.
+ *
+ * This website is loaded when the incoming HTTP host matches
+ * the domain pattern defined in \$domains.
+ */
+class Boot extends ServiceProvider
+{
+ /**
+ * Domain patterns this website responds to.
+ *
+ * Uses regex patterns. Common examples:
+ * - '/^example\\.test\$/' - exact match
+ * - '/^example\\.(com|test)\$/' - multiple TLDs
+ * - '/^(www\\.)?example\\.com\$/' - optional www
+ *
+ * @var array
+ */
+ public static array \$domains = [
+ '{$domainPattern}',
+ ];
+
+ /**
+ * Events this module listens to for lazy loading.
+ *
+ * @var array
+ */
+ public static array \$listens = [
+ DomainResolving::class => 'onDomainResolving',
+{$listeners}
+ ];
+
+ /**
+ * Register any application services.
+ */
+ public function register(): void
+ {
+ //
+ }
+
+ /**
+ * Bootstrap any application services.
+ */
+ public function boot(): void
+ {
+ //
+ }
+
+ /**
+ * Handle domain resolution - register if domain matches.
+ */
+ public function onDomainResolving(DomainResolving \$event): void
+ {
+ foreach (self::\$domains as \$pattern) {
+ if (\$event->matches(\$pattern)) {
+ \$event->register(self::class);
+
+ return;
+ }
+ }
+ }
+{$handlers}
+}
+
+PHP;
+
+ File::put("{$websitePath}/Boot.php", $content);
+ $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Domain-isolated website provider'];
+ $this->components->task('Creating Boot.php', fn () => true);
+ }
+
+ /**
+ * Build the domain regex pattern.
+ */
+ protected function buildDomainPattern(string $domain): string
+ {
+ // Escape dots and create a regex pattern
+ $escaped = preg_quote($domain, '/');
+
+ return '/^'.$escaped.'$/';
+ }
+
+ /**
+ * Build the use statements for the Boot file.
+ */
+ protected function buildUseStatements(): string
+ {
+ $statements = [];
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $statements[] = 'use Core\Events\WebRoutesRegistering;';
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $statements[] = 'use Core\Events\AdminPanelBooting;';
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $statements[] = 'use Core\Events\ApiRoutesRegistering;';
+ }
+
+ return implode("\n", $statements);
+ }
+
+ /**
+ * Build the listeners array content (excluding DomainResolving).
+ */
+ protected function buildListenersArray(): string
+ {
+ $listeners = [];
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',";
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $listeners[] = " AdminPanelBooting::class => 'onAdminPanel',";
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',";
+ }
+
+ return implode("\n", $listeners);
+ }
+
+ /**
+ * Build the handler methods.
+ */
+ protected function buildHandlerMethods(string $name): string
+ {
+ $methods = [];
+ $websiteName = Str::snake($name);
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $methods[] = <<views('{$websiteName}', __DIR__.'/View/Blade');
+
+ if (file_exists(__DIR__.'/Routes/web.php')) {
+ \$event->routes(fn () => require __DIR__.'/Routes/web.php');
+ }
+ }
+PHP;
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $methods[] = <<<'PHP'
+
+ /**
+ * Register admin panel routes.
+ */
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ if (file_exists(__DIR__.'/Routes/admin.php')) {
+ $event->routes(fn () => require __DIR__.'/Routes/admin.php');
+ }
+ }
+PHP;
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $methods[] = <<<'PHP'
+
+ /**
+ * Register API routes.
+ */
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ if (file_exists(__DIR__.'/Routes/api.php')) {
+ $event->routes(fn () => require __DIR__.'/Routes/api.php');
+ }
+ }
+PHP;
+ }
+
+ return implode("\n", $methods);
+ }
+
+ /**
+ * Create optional files based on flags.
+ */
+ protected function createOptionalFiles(string $websitePath, string $name): void
+ {
+ $websiteName = Str::snake($name);
+
+ if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
+ $this->createWebRoutes($websitePath, $websiteName);
+ $this->createLayout($websitePath, $name);
+ $this->createHomepage($websitePath, $websiteName);
+ }
+
+ if ($this->option('admin') || $this->option('all')) {
+ $this->createAdminRoutes($websitePath, $websiteName);
+ }
+
+ if ($this->option('api') || $this->option('all')) {
+ $this->createApiRoutes($websitePath, $websiteName);
+ }
+ }
+
+ /**
+ * Create web routes file.
+ */
+ protected function createWebRoutes(string $websitePath, string $websiteName): void
+ {
+ $content = <<name('{$websiteName}.home');
+
+PHP;
+
+ File::put("{$websitePath}/Routes/web.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes'];
+ $this->components->task('Creating Routes/web.php', fn () => true);
+ }
+
+ /**
+ * Create admin routes file.
+ */
+ protected function createAdminRoutes(string $websitePath, string $websiteName): void
+ {
+ $content = <<name('{$websiteName}.admin.')->group(function () {
+ Route::get('/', function () {
+ return 'Admin dashboard for {$websiteName}';
+ })->name('index');
+});
+
+PHP;
+
+ File::put("{$websitePath}/Routes/admin.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes'];
+ $this->components->task('Creating Routes/admin.php', fn () => true);
+ }
+
+ /**
+ * Create API routes file.
+ */
+ protected function createApiRoutes(string $websitePath, string $websiteName): void
+ {
+ $content = <<name('api.{$websiteName}.')->group(function () {
+ Route::get('/health', function () {
+ return response()->json(['status' => 'ok', 'website' => '{$websiteName}']);
+ })->name('health');
+});
+
+PHP;
+
+ File::put("{$websitePath}/Routes/api.php", $content);
+ $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes'];
+ $this->components->task('Creating Routes/api.php', fn () => true);
+ }
+
+ /**
+ * Create a base layout file.
+ */
+ protected function createLayout(string $websitePath, string $name): void
+ {
+ $content = <<
+
+
+
+
+
+
+ {{ \$title ?? '{$name}' }}
+
+
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+
+
+
+
+
+ {{ \$slot }}
+
+
+
+
+
+BLADE;
+
+ File::put("{$websitePath}/View/Blade/layouts/app.blade.php", $content);
+ $this->createdFiles[] = ['file' => 'View/Blade/layouts/app.blade.php', 'description' => 'Base layout template'];
+ $this->components->task('Creating View/Blade/layouts/app.blade.php', fn () => true);
+ }
+
+ /**
+ * Create a homepage view.
+ */
+ protected function createHomepage(string $websitePath, string $websiteName): void
+ {
+ $name = Str::studly($websiteName);
+
+ $content = <<
+ Welcome - {$name}
+
+
+
+
+
+
Welcome to {$name}
+
+ This is your new website. Start building something amazing!
+
+
+
+
+
+
+
+BLADE;
+
+ File::put("{$websitePath}/View/Blade/home.blade.php", $content);
+ $this->createdFiles[] = ['file' => 'View/Blade/home.blade.php', 'description' => 'Homepage view'];
+ $this->components->task('Creating View/Blade/home.blade.php', fn () => true);
+ }
+
+ /**
+ * Get shell completion suggestions for arguments and options.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ if ($input->mustSuggestArgumentValuesFor('name')) {
+ // Suggest common website naming patterns
+ $suggestions->suggestValues([
+ 'MarketingSite',
+ 'Blog',
+ 'Documentation',
+ 'LandingPage',
+ 'Portal',
+ 'Dashboard',
+ 'Support',
+ ]);
+ }
+
+ if ($input->mustSuggestOptionValuesFor('domain')) {
+ // Suggest common development domains
+ $suggestions->suggestValues([
+ 'example.test',
+ 'app.test',
+ 'site.test',
+ 'dev.test',
+ ]);
+ }
+ }
+}
diff --git a/src/Core/Console/Commands/NewProjectCommand.php b/src/Core/Console/Commands/NewProjectCommand.php
new file mode 100644
index 0000000..2005d2c
--- /dev/null
+++ b/src/Core/Console/Commands/NewProjectCommand.php
@@ -0,0 +1,368 @@
+argument('name');
+ $directory = getcwd().'/'.$name;
+
+ // Validate project name
+ if (! $this->validateProjectName($name)) {
+ return self::FAILURE;
+ }
+
+ // Check if directory exists
+ if (File::isDirectory($directory) && ! $this->option('force')) {
+ $this->newLine();
+ $this->components->error("Directory [{$name}] already exists!");
+ $this->newLine();
+ $this->components->warn('Use --force to overwrite the existing directory.');
+ $this->newLine();
+
+ return self::FAILURE;
+ }
+
+ $this->newLine();
+ $this->components->info(' ╔═══════════════════════════════════════════╗');
+ $this->components->info(' ║ Core PHP Framework Project Creator ║');
+ $this->components->info(' ╚═══════════════════════════════════════════╝');
+ $this->newLine();
+
+ $template = $this->option('template') ?: $this->defaultTemplate;
+ $this->components->twoColumnDetail('Project Name>', $name);
+ $this->components->twoColumnDetail('Template>', $template);
+ $this->components->twoColumnDetail('Directory>', $directory);
+ $this->newLine();
+
+ try {
+ // Step 1: Create project from template
+ $this->components->task('Creating project from template', function () use ($directory, $template, $name) {
+ return $this->createFromTemplate($directory, $template, $name);
+ });
+
+ // Step 2: Install dependencies
+ if (! $this->option('no-install')) {
+ $this->components->task('Installing Composer dependencies', function () use ($directory) {
+ return $this->installDependencies($directory);
+ });
+
+ // Step 3: Run core:install
+ $this->components->task('Running framework installation', function () use ($directory) {
+ return $this->runCoreInstall($directory);
+ });
+ }
+
+ // Success!
+ $this->newLine();
+ $this->components->info(' ✓ Project created successfully!');
+ $this->newLine();
+
+ $this->components->info(' Next steps:');
+ $this->line(" 1.> cd {$name}");
+ if ($this->option('no-install')) {
+ $this->line(' 2.> composer install');
+ $this->line(' 3.> php artisan core:install');
+ $this->line(' 4.> php artisan serve');
+ } else {
+ $this->line(' 2.> php artisan serve');
+ }
+ $this->newLine();
+
+ $this->showPackageInfo();
+
+ return self::SUCCESS;
+ } catch (\Throwable $e) {
+ $this->newLine();
+ $this->components->error(' Project creation failed: '.$e->getMessage());
+ $this->newLine();
+
+ // Cleanup on failure
+ if (File::isDirectory($directory)) {
+ $cleanup = $this->confirm('Remove failed project directory?', true);
+ if ($cleanup) {
+ File::deleteDirectory($directory);
+ $this->components->info(' Cleaned up project directory.');
+ }
+ }
+
+ return self::FAILURE;
+ }
+ }
+
+ /**
+ * Validate project name.
+ */
+ protected function validateProjectName(string $name): bool
+ {
+ if (empty($name)) {
+ $this->components->error('Project name cannot be empty');
+
+ return false;
+ }
+
+ if (! preg_match('/^[a-z0-9_-]+$/i', $name)) {
+ $this->components->error('Project name can only contain letters, numbers, hyphens, and underscores');
+
+ return false;
+ }
+
+ if (in_array(strtolower($name), ['vendor', 'app', 'test', 'tests', 'src', 'public'])) {
+ $this->components->error("Project name '{$name}' is reserved");
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Create project from template repository.
+ */
+ protected function createFromTemplate(string $directory, string $template, string $projectName): bool
+ {
+ $branch = $this->option('branch');
+
+ // If force, delete existing directory
+ if ($this->option('force') && File::isDirectory($directory)) {
+ File::deleteDirectory($directory);
+ }
+
+ // Check if template is a URL or repo slug
+ $templateUrl = $this->resolveTemplateUrl($template);
+
+ // Clone the template
+ $result = Process::run("git clone --branch {$branch} --single-branch --depth 1 {$templateUrl} {$directory}");
+
+ if (! $result->successful()) {
+ throw new \RuntimeException("Failed to clone template: {$result->errorOutput()}");
+ }
+
+ // Remove .git directory to make it a fresh repo
+ File::deleteDirectory("{$directory}/.git");
+
+ // Update composer.json with project name
+ $this->updateComposerJson($directory, $projectName);
+
+ // Initialize new git repository
+ Process::run("cd {$directory} && git init");
+ Process::run("cd {$directory} && git add .");
+ Process::run("cd {$directory} && git commit -m \"Initial commit from Core PHP Framework template\"");
+
+ return true;
+ }
+
+ /**
+ * Resolve template to full git URL.
+ */
+ protected function resolveTemplateUrl(string $template): string
+ {
+ // If already a URL, return as-is
+ if (str_starts_with($template, 'http://') || str_starts_with($template, 'https://')) {
+ return $template;
+ }
+
+ // If contains .git, treat as SSH URL
+ if (str_contains($template, '.git')) {
+ return $template;
+ }
+
+ // Otherwise, assume GitHub slug
+ return "https://github.com/{$template}.git";
+ }
+
+ /**
+ * Update composer.json with project name.
+ */
+ protected function updateComposerJson(string $directory, string $projectName): void
+ {
+ $composerPath = "{$directory}/composer.json";
+ if (! File::exists($composerPath)) {
+ return;
+ }
+
+ $composer = json_decode(File::get($composerPath), true);
+ $composer['name'] = $this->generateComposerName($projectName);
+ $composer['description'] = "Core PHP Framework application - {$projectName}";
+
+ // Update namespace if using default App namespace
+ if (isset($composer['autoload']['psr-4']['App\\'])) {
+ $studlyName = Str::studly($projectName);
+ $composer['autoload']['psr-4']["{$studlyName}\\"] = 'app/';
+ unset($composer['autoload']['psr-4']['App\\']);
+ }
+
+ File::put($composerPath, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ /**
+ * Generate composer package name from project name.
+ */
+ protected function generateComposerName(string $projectName): string
+ {
+ $vendor = $this->ask('Composer vendor name', 'my-company');
+ $package = Str::slug($projectName);
+
+ return "{$vendor}/{$package}";
+ }
+
+ /**
+ * Install composer dependencies.
+ */
+ protected function installDependencies(string $directory): bool
+ {
+ $composerBin = $this->findComposer();
+
+ $command = $this->option('dev')
+ ? "{$composerBin} install --prefer-source"
+ : "{$composerBin} install";
+
+ $result = Process::run("cd {$directory} && {$command}");
+
+ if (! $result->successful()) {
+ throw new \RuntimeException("Composer install failed: {$result->errorOutput()}");
+ }
+
+ return true;
+ }
+
+ /**
+ * Run core:install command.
+ */
+ protected function runCoreInstall(string $directory): bool
+ {
+ $result = Process::run("cd {$directory} && php artisan core:install --no-interaction");
+
+ if (! $result->successful()) {
+ throw new \RuntimeException("core:install failed: {$result->errorOutput()}");
+ }
+
+ return true;
+ }
+
+ /**
+ * Find the composer binary.
+ */
+ protected function findComposer(): string
+ {
+ // Check if composer is in PATH
+ $result = Process::run('which composer');
+ if ($result->successful()) {
+ return trim($result->output());
+ }
+
+ // Check common locations
+ $locations = [
+ '/usr/local/bin/composer',
+ '/usr/bin/composer',
+ $_SERVER['HOME'].'/.composer/composer.phar',
+ ];
+
+ foreach ($locations as $location) {
+ if (File::exists($location)) {
+ return $location;
+ }
+ }
+
+ return 'composer'; // Fallback, will fail if not in PATH
+ }
+
+ /**
+ * Show package information.
+ */
+ protected function showPackageInfo(): void
+ {
+ $this->components->info(' 📦 Installed Core PHP Packages:');
+ $this->components->twoColumnDetail(' host-uk/core>', 'Core framework components');
+ $this->components->twoColumnDetail(' host-uk/core-admin>', 'Admin panel & Livewire modals');
+ $this->components->twoColumnDetail(' host-uk/core-api>', 'REST API with scopes & webhooks');
+ $this->components->twoColumnDetail(' host-uk/core-mcp>', 'Model Context Protocol tools');
+ $this->newLine();
+
+ $this->components->info(' 📚 Documentation:');
+ $this->components->twoColumnDetail(' https://github.com/host-uk/core-php>', 'GitHub Repository');
+ $this->components->twoColumnDetail(' https://docs.core-php.dev>', 'Official Docs (future)');
+ $this->newLine();
+ }
+
+ /**
+ * Get shell completion suggestions.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ if ($input->mustSuggestArgumentValuesFor('name')) {
+ // Suggest common project naming patterns
+ $suggestions->suggestValues([
+ 'my-app',
+ 'api-service',
+ 'admin-panel',
+ 'saas-platform',
+ ]);
+ }
+
+ if ($input->mustSuggestOptionValuesFor('template')) {
+ // Suggest known templates
+ $suggestions->suggestValues([
+ 'host-uk/core-template',
+ 'host-uk/core-api-template',
+ 'host-uk/core-admin-template',
+ ]);
+ }
+ }
+}
diff --git a/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php b/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php
new file mode 100644
index 0000000..fa231e3
--- /dev/null
+++ b/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php
@@ -0,0 +1,146 @@
+command('email-shield:prune')->daily();
+ */
+class PruneEmailShieldStatsCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ */
+ protected $signature = 'email-shield:prune
+ {--days= : Number of days to retain (default: from config or 90)}
+ {--dry-run : Show what would be deleted without actually deleting}';
+
+ /**
+ * The console command description.
+ */
+ protected $description = 'Prune old Email Shield statistics records';
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $days = $this->getRetentionDays();
+ $dryRun = $this->option('dry-run');
+
+ $this->newLine();
+ $this->components->info('Email Shield Stats Cleanup');
+ $this->newLine();
+
+ // Get count of records that would be deleted
+ $cutoffDate = now()->subDays($days)->format('Y-m-d');
+ $recordsToDelete = EmailShieldStat::query()
+ ->where('date', '<', $cutoffDate)
+ ->count();
+
+ // Show current state table
+ $this->components->twoColumnDetail('Configuration>', '');
+ $this->components->twoColumnDetail('Retention period', "{$days} days>");
+ $this->components->twoColumnDetail('Cutoff date', "{$cutoffDate}>");
+ $this->components->twoColumnDetail('Records to delete', $recordsToDelete > 0
+ ? "{$recordsToDelete}>"
+ : '0>');
+ $this->newLine();
+
+ if ($recordsToDelete === 0) {
+ $this->components->info('No records older than the retention period found.');
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ if ($dryRun) {
+ $this->components->warn('Dry run mode - no records were deleted.');
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ // Show progress for deletion
+ $this->components->task(
+ "Deleting {$recordsToDelete} old records",
+ function () use ($days) {
+ EmailShieldStat::pruneOldRecords($days);
+
+ return true;
+ }
+ );
+
+ $this->newLine();
+ $this->components->info("Successfully deleted {$recordsToDelete} records older than {$days} days.");
+ $this->newLine();
+
+ // Show remaining stats
+ $remaining = EmailShieldStat::getRecordCount();
+ $oldest = EmailShieldStat::getOldestRecordDate();
+
+ $this->components->twoColumnDetail('Current State>', '');
+ $this->components->twoColumnDetail('Remaining records', "{$remaining}>");
+ if ($oldest) {
+ $this->components->twoColumnDetail('Oldest record', "{$oldest->format('Y-m-d')}>");
+ }
+ $this->newLine();
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Get the retention period in days from option, config, or default.
+ */
+ protected function getRetentionDays(): int
+ {
+ // First check command option
+ $days = $this->option('days');
+ if ($days !== null) {
+ return (int) $days;
+ }
+
+ // Then check config
+ $configDays = config('core.email_shield.retention_days');
+ if ($configDays !== null) {
+ return (int) $configDays;
+ }
+
+ // Default to 90 days
+ return 90;
+ }
+
+ /**
+ * Get shell completion suggestions for options.
+ */
+ public function complete(
+ \Symfony\Component\Console\Completion\CompletionInput $input,
+ \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
+ ): void {
+ if ($input->mustSuggestOptionValuesFor('days')) {
+ // Suggest common retention periods
+ $suggestions->suggestValues(['7', '14', '30', '60', '90', '180', '365']);
+ }
+ }
+}
diff --git a/src/Core/Crypt/EncryptArrayObject.php b/src/Core/Crypt/EncryptArrayObject.php
new file mode 100644
index 0000000..3533124
--- /dev/null
+++ b/src/Core/Crypt/EncryptArrayObject.php
@@ -0,0 +1,85 @@
+ $attributes
+ */
+ public function get($model, string $key, $value, array $attributes): ?ArrayObject
+ {
+ if (isset($attributes[$key])) {
+ try {
+ $decrypted = Crypt::decryptString($attributes[$key]);
+ } catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
+ Log::warning('Failed to decrypt array object', ['key' => $key, 'error' => $e->getMessage()]);
+
+ return null;
+ }
+
+ $decoded = json_decode($decrypted, true);
+
+ if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
+ Log::warning('Failed to decode encrypted array', ['key' => $key, 'error' => json_last_error_msg()]);
+
+ return null;
+ }
+
+ return new ArrayObject($decoded ?? []);
+ }
+
+ return null;
+ }
+
+ /**
+ * Prepare the given value for storage.
+ *
+ * @param \Illuminate\Database\Eloquent\Model $model
+ * @param mixed $value
+ * @param array $attributes
+ * @return array|null
+ */
+ public function set($model, string $key, $value, array $attributes): ?array
+ {
+ if (! is_null($value)) {
+ $encoded = json_encode($value);
+
+ if ($encoded === false) {
+ throw new \RuntimeException(
+ "Failed to encode value for encryption [{$key}]: ".json_last_error_msg()
+ );
+ }
+
+ $encrypted = Crypt::encryptString($encoded);
+
+ return [$key => $encrypted];
+ }
+
+ return null;
+ }
+}
diff --git a/src/Core/Crypt/LthnHash.php b/src/Core/Crypt/LthnHash.php
new file mode 100644
index 0000000..03710c5
--- /dev/null
+++ b/src/Core/Crypt/LthnHash.php
@@ -0,0 +1,519 @@
+ int | 60 bits | Sharding/partitioning |
+ *
+ * ## Security Properties
+ *
+ * This is a "QuasiHash" - a deterministic identifier generator, NOT a cryptographic hash.
+ *
+ * **What it provides:**
+ * - Deterministic output: same input always produces same output
+ * - Uniform distribution: outputs are evenly distributed across the hash space
+ * - Avalanche effect: small input changes produce significantly different outputs
+ * - Collision resistance proportional to output length (see table below)
+ *
+ * **What it does NOT provide:**
+ * - Pre-image resistance: attackers can potentially reverse the hash
+ * - Cryptographic security: the key map is not a secret
+ * - Protection against brute force: short hashes can be enumerated
+ *
+ * ## Collision Resistance by Length
+ *
+ * | Length | Bits | Collision Probability (10k items) | Use Case |
+ * |--------|------|-----------------------------------|----------|
+ * | 16 | 64 | ~1 in 3.4 billion | Internal IDs, low-volume |
+ * | 24 | 96 | ~1 in 79 quintillion | Cross-system IDs |
+ * | 32 | 128 | ~1 in 3.4e38 | Long-term storage |
+ * | 64 | 256 | Negligible | Maximum security |
+ *
+ * ## Performance Considerations
+ *
+ * For short inputs (< 64 bytes), the default SHA-256 implementation is suitable
+ * for most use cases. For extremely high-throughput scenarios with many short
+ * strings, consider using `fastHash()` which uses xxHash (when available) or
+ * a CRC32-based approach for better performance.
+ *
+ * Benchmark reference (typical values, YMMV):
+ * - SHA-256: ~300k hashes/sec for short strings
+ * - xxHash (via hash extension): ~2M hashes/sec for short strings
+ * - CRC32: ~1.5M hashes/sec for short strings
+ *
+ * Use `benchmark()` to measure actual performance on your system.
+ *
+ * ## Key Rotation
+ *
+ * The class supports multiple key maps for rotation. When verifying, all registered
+ * key maps are tried in order (newest first). This allows gradual migration:
+ *
+ * 1. Add new key map with `addKeyMap()`
+ * 2. New hashes use the new key map
+ * 3. Verification tries new key first, falls back to old
+ * 4. After migration period, remove old key map with `removeKeyMap()`
+ *
+ * ## Usage Examples
+ *
+ * ```php
+ * // Generate a vBucket ID for CDN path isolation
+ * $vbucket = LthnHash::vBucketId('workspace.example.com');
+ * // => "a7b3c9d2e1f4g5h6..."
+ *
+ * // Generate a short ID for internal use
+ * $shortId = LthnHash::shortHash('user-12345', LthnHash::MEDIUM_LENGTH);
+ * // => "a7b3c9d2e1f4g5h6i8j9k1l2"
+ *
+ * // High-throughput scenario
+ * $fastId = LthnHash::fastHash('cache-key-123');
+ * // => "1a2b3c4d5e6f7g8h"
+ *
+ * // Sharding: get consistent partition number
+ * $partition = LthnHash::toInt('user@example.com', 16);
+ * // => 7 (always 7 for this input)
+ *
+ * // Verify a hash
+ * $isValid = LthnHash::verify('user-12345', $shortId);
+ * // => true
+ * ```
+ *
+ * ## NOT Suitable For
+ *
+ * - Password hashing (use `password_hash()` instead)
+ * - Security tokens (use `random_bytes()` instead)
+ * - Cryptographic signatures
+ * - Any security-sensitive operations
+ */
+class LthnHash
+{
+ /**
+ * Default output length for short hash (16 hex chars = 64 bits).
+ */
+ public const SHORT_LENGTH = 16;
+
+ /**
+ * Medium output length for improved collision resistance (24 hex chars = 96 bits).
+ */
+ public const MEDIUM_LENGTH = 24;
+
+ /**
+ * Long output length for high collision resistance (32 hex chars = 128 bits).
+ */
+ public const LONG_LENGTH = 32;
+
+ /**
+ * Default key map identifier.
+ */
+ public const DEFAULT_KEY = 'default';
+
+ /**
+ * Character-swapping key maps for quasi-salting.
+ * Swaps pairs of characters during encoding.
+ *
+ * Multiple key maps can be registered for key rotation.
+ * The first key map is used for new hashes; all are tried during verification.
+ *
+ * @var array>
+ */
+ protected static array $keyMaps = [
+ 'default' => [
+ 'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w',
+ 'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u',
+ 'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's',
+ 'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q',
+ '0' => 'z', '5' => 'y',
+ 's' => 'z', 't' => '7',
+ ],
+ ];
+
+ /**
+ * The currently active key map identifier for generating new hashes.
+ */
+ protected static string $activeKey = self::DEFAULT_KEY;
+
+ /**
+ * Generate a deterministic quasi-hash from input.
+ *
+ * Creates a salt by reversing the input and applying character
+ * substitution, then hashes input + salt with SHA-256.
+ *
+ * @param string $input The input string to hash
+ * @param string|null $keyId Key map identifier (null uses active key)
+ * @return string 64-character SHA-256 hex string
+ */
+ public static function hash(string $input, ?string $keyId = null): string
+ {
+ $keyId ??= self::$activeKey;
+
+ // Create salt by reversing input and applying substitution
+ $reversed = strrev($input);
+ $salt = self::applyKeyMap($reversed, $keyId);
+
+ // Hash input + salt
+ return hash('sha256', $input.$salt);
+ }
+
+ /**
+ * Generate a short hash (prefix of full hash).
+ *
+ * @param string $input The input string to hash
+ * @param int $length Output length in hex characters (default: SHORT_LENGTH)
+ */
+ public static function shortHash(string $input, int $length = self::SHORT_LENGTH): string
+ {
+ if ($length < 1 || $length > 64) {
+ throw new \InvalidArgumentException('Hash length must be between 1 and 64');
+ }
+
+ return substr(self::hash($input), 0, $length);
+ }
+
+ /**
+ * Generate a vBucket ID for a domain/workspace.
+ *
+ * Format: 64-character SHA-256 hex string
+ *
+ * @param string $domain The domain or workspace identifier
+ */
+ public static function vBucketId(string $domain): string
+ {
+ // Normalize domain (lowercase, trim)
+ $normalized = strtolower(trim($domain));
+
+ return self::hash($normalized);
+ }
+
+ /**
+ * Verify that a hash matches an input using constant-time comparison.
+ *
+ * Tries all registered key maps in order (active key first, then others).
+ * This supports key rotation: old hashes remain verifiable while new hashes
+ * use the current active key.
+ *
+ * SECURITY NOTE: This method uses hash_equals() for constant-time string
+ * comparison, which prevents timing attacks. Regular string comparison
+ * (== or ===) can leak information about the hash through timing differences.
+ * Always use this method for hash verification rather than direct comparison.
+ *
+ * @param string $input The original input
+ * @param string $hash The hash to verify
+ * @return bool True if the hash matches with any registered key map
+ */
+ public static function verify(string $input, string $hash): bool
+ {
+ $hashLength = strlen($hash);
+
+ // Try active key first
+ $computed = self::hash($input, self::$activeKey);
+ if ($hashLength < 64) {
+ $computed = substr($computed, 0, $hashLength);
+ }
+ if (hash_equals($computed, $hash)) {
+ return true;
+ }
+
+ // Try other key maps for rotation support
+ foreach (array_keys(self::$keyMaps) as $keyId) {
+ if ($keyId === self::$activeKey) {
+ continue;
+ }
+
+ $computed = self::hash($input, $keyId);
+ if ($hashLength < 64) {
+ $computed = substr($computed, 0, $hashLength);
+ }
+ if (hash_equals($computed, $hash)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Apply the key map character swapping.
+ *
+ * @param string $input The input string to transform
+ * @param string $keyId Key map identifier
+ */
+ protected static function applyKeyMap(string $input, string $keyId): string
+ {
+ $keyMap = self::$keyMaps[$keyId] ?? self::$keyMaps[self::DEFAULT_KEY];
+ $output = '';
+
+ for ($i = 0; $i < strlen($input); $i++) {
+ $char = $input[$i];
+ $output .= $keyMap[$char] ?? $char;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Get the current active key map.
+ *
+ * @return array
+ */
+ public static function getKeyMap(): array
+ {
+ return self::$keyMaps[self::$activeKey] ?? self::$keyMaps[self::DEFAULT_KEY];
+ }
+
+ /**
+ * Get all registered key maps.
+ *
+ * @return array>
+ */
+ public static function getKeyMaps(): array
+ {
+ return self::$keyMaps;
+ }
+
+ /**
+ * Set a custom key map (replaces the active key map).
+ *
+ * @param array $keyMap Character substitution map
+ */
+ public static function setKeyMap(array $keyMap): void
+ {
+ self::$keyMaps[self::$activeKey] = $keyMap;
+ }
+
+ /**
+ * Add a new key map for rotation.
+ *
+ * @param string $keyId Unique identifier for this key map
+ * @param array $keyMap Character substitution map
+ * @param bool $setActive Whether to make this the active key for new hashes
+ */
+ public static function addKeyMap(string $keyId, array $keyMap, bool $setActive = true): void
+ {
+ self::$keyMaps[$keyId] = $keyMap;
+
+ if ($setActive) {
+ self::$activeKey = $keyId;
+ }
+ }
+
+ /**
+ * Remove a key map.
+ *
+ * Cannot remove the default key map or the currently active key map.
+ *
+ * @param string $keyId Key map identifier to remove
+ *
+ * @throws \InvalidArgumentException If attempting to remove default or active key
+ */
+ public static function removeKeyMap(string $keyId): void
+ {
+ if ($keyId === self::DEFAULT_KEY) {
+ throw new \InvalidArgumentException('Cannot remove the default key map');
+ }
+
+ if ($keyId === self::$activeKey) {
+ throw new \InvalidArgumentException('Cannot remove the active key map. Set a different active key first.');
+ }
+
+ unset(self::$keyMaps[$keyId]);
+ }
+
+ /**
+ * Get the active key map identifier.
+ */
+ public static function getActiveKey(): string
+ {
+ return self::$activeKey;
+ }
+
+ /**
+ * Set the active key map for generating new hashes.
+ *
+ * @param string $keyId Key map identifier (must already be registered)
+ *
+ * @throws \InvalidArgumentException If key map does not exist
+ */
+ public static function setActiveKey(string $keyId): void
+ {
+ if (! isset(self::$keyMaps[$keyId])) {
+ throw new \InvalidArgumentException("Key map '{$keyId}' does not exist");
+ }
+
+ self::$activeKey = $keyId;
+ }
+
+ /**
+ * Reset to default state.
+ *
+ * Removes all custom key maps and resets to the default key map.
+ */
+ public static function reset(): void
+ {
+ self::$keyMaps = [
+ self::DEFAULT_KEY => [
+ 'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w',
+ 'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u',
+ 'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's',
+ 'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q',
+ '0' => 'z', '5' => 'y',
+ 's' => 'z', 't' => '7',
+ ],
+ ];
+ self::$activeKey = self::DEFAULT_KEY;
+ }
+
+ /**
+ * Generate a deterministic integer from input.
+ * Useful for consistent sharding/partitioning.
+ *
+ * @param string $input The input string
+ * @param int $max Maximum value (exclusive)
+ */
+ public static function toInt(string $input, int $max = PHP_INT_MAX): int
+ {
+ $hash = self::hash($input);
+ // Use first 15 hex chars (60 bits) for safe int conversion
+ $hex = substr($hash, 0, 15);
+
+ return gmp_intval(gmp_mod(gmp_init($hex, 16), $max));
+ }
+
+ /**
+ * Generate a fast hash for performance-critical operations.
+ *
+ * Uses xxHash when available (via hash extension), falling back to a
+ * CRC32-based approach. This is significantly faster than SHA-256 for
+ * short inputs but provides less collision resistance.
+ *
+ * Best for:
+ * - High-throughput scenarios (millions of hashes)
+ * - Cache keys and temporary identifiers
+ * - Hash table bucketing
+ *
+ * NOT suitable for:
+ * - Long-term storage identifiers
+ * - Security-sensitive operations
+ * - Cases requiring strong collision resistance
+ *
+ * @param string $input The input string to hash
+ * @param int $length Output length in hex characters (max 16 for xxh64, 8 for crc32)
+ * @return string Hex hash string
+ */
+ public static function fastHash(string $input, int $length = 16): string
+ {
+ // Apply key map for consistency with standard hash
+ $keyId = self::$activeKey;
+ $reversed = strrev($input);
+ $salted = $input.self::applyKeyMap($reversed, $keyId);
+
+ // Use xxHash if available (PHP 8.1+ with hash extension)
+ if (in_array('xxh64', hash_algos(), true)) {
+ $hash = hash('xxh64', $salted);
+
+ return substr($hash, 0, min($length, 16));
+ }
+
+ // Fallback: combine two CRC32 variants for 16 hex chars
+ $crc1 = hash('crc32b', $salted);
+ $crc2 = hash('crc32c', strrev($salted));
+ $combined = $crc1.$crc2;
+
+ return substr($combined, 0, min($length, 16));
+ }
+
+ /**
+ * Run a simple benchmark comparing hash algorithms.
+ *
+ * Returns timing data for hash(), shortHash(), and fastHash() to help
+ * choose the appropriate method for your use case.
+ *
+ * @param int $iterations Number of hash operations to run
+ * @param string|null $testInput Input string to hash (default: random 32 chars)
+ * @return array{
+ * hash: array{iterations: int, total_ms: float, per_hash_us: float},
+ * shortHash: array{iterations: int, total_ms: float, per_hash_us: float},
+ * fastHash: array{iterations: int, total_ms: float, per_hash_us: float},
+ * fastHash_algorithm: string
+ * }
+ */
+ public static function benchmark(int $iterations = 10000, ?string $testInput = null): array
+ {
+ $testInput ??= bin2hex(random_bytes(16)); // 32 char test string
+
+ // Benchmark hash()
+ $start = hrtime(true);
+ for ($i = 0; $i < $iterations; $i++) {
+ self::hash($testInput.$i);
+ }
+ $hashTime = (hrtime(true) - $start) / 1e6; // Convert to ms
+
+ // Benchmark shortHash()
+ $start = hrtime(true);
+ for ($i = 0; $i < $iterations; $i++) {
+ self::shortHash($testInput.$i);
+ }
+ $shortHashTime = (hrtime(true) - $start) / 1e6;
+
+ // Benchmark fastHash()
+ $start = hrtime(true);
+ for ($i = 0; $i < $iterations; $i++) {
+ self::fastHash($testInput.$i);
+ }
+ $fastHashTime = (hrtime(true) - $start) / 1e6;
+
+ // Determine which algorithm fastHash is using
+ $fastHashAlgo = in_array('xxh64', hash_algos(), true) ? 'xxh64' : 'crc32b+crc32c';
+
+ return [
+ 'hash' => [
+ 'iterations' => $iterations,
+ 'total_ms' => round($hashTime, 2),
+ 'per_hash_us' => round(($hashTime * 1000) / $iterations, 3),
+ ],
+ 'shortHash' => [
+ 'iterations' => $iterations,
+ 'total_ms' => round($shortHashTime, 2),
+ 'per_hash_us' => round(($shortHashTime * 1000) / $iterations, 3),
+ ],
+ 'fastHash' => [
+ 'iterations' => $iterations,
+ 'total_ms' => round($fastHashTime, 2),
+ 'per_hash_us' => round(($fastHashTime * 1000) / $iterations, 3),
+ ],
+ 'fastHash_algorithm' => $fastHashAlgo,
+ ];
+ }
+}
diff --git a/src/Core/Database/Seeders/Attributes/SeederAfter.php b/src/Core/Database/Seeders/Attributes/SeederAfter.php
new file mode 100644
index 0000000..9917701
--- /dev/null
+++ b/src/Core/Database/Seeders/Attributes/SeederAfter.php
@@ -0,0 +1,60 @@
+
+ */
+ public readonly array $seeders;
+
+ /**
+ * Create a new dependency attribute.
+ *
+ * @param class-string ...$seeders Seeder classes that must run first
+ */
+ public function __construct(string ...$seeders)
+ {
+ $this->seeders = $seeders;
+ }
+}
diff --git a/src/Core/Database/Seeders/Attributes/SeederBefore.php b/src/Core/Database/Seeders/Attributes/SeederBefore.php
new file mode 100644
index 0000000..0b525dc
--- /dev/null
+++ b/src/Core/Database/Seeders/Attributes/SeederBefore.php
@@ -0,0 +1,61 @@
+
+ */
+ public readonly array $seeders;
+
+ /**
+ * Create a new dependency attribute.
+ *
+ * @param class-string ...$seeders Seeder classes that must run after this one
+ */
+ public function __construct(string ...$seeders)
+ {
+ $this->seeders = $seeders;
+ }
+}
diff --git a/src/Core/Database/Seeders/Attributes/SeederPriority.php b/src/Core/Database/Seeders/Attributes/SeederPriority.php
new file mode 100644
index 0000000..c5b3e0b
--- /dev/null
+++ b/src/Core/Database/Seeders/Attributes/SeederPriority.php
@@ -0,0 +1,61 @@
+getSeedersToRun();
+
+ if (empty($seeders)) {
+ $this->info('No seeders found to run.');
+
+ return;
+ }
+
+ $this->info(sprintf('Running %d seeders...', count($seeders)));
+ $this->newLine();
+
+ foreach ($seeders as $seeder) {
+ $shortName = $this->getShortName($seeder);
+ $this->info("Running: {$shortName}");
+
+ $this->call($seeder);
+ }
+
+ $this->newLine();
+ $this->info('Database seeding completed successfully.');
+ }
+
+ /**
+ * Get the list of seeders to run.
+ *
+ * @return array Ordered list of seeder class names
+ */
+ protected function getSeedersToRun(): array
+ {
+ $seeders = $this->discoverSeeders();
+
+ // Apply filters
+ $seeders = $this->applyExcludeFilter($seeders);
+ $seeders = $this->applyOnlyFilter($seeders);
+
+ return $seeders;
+ }
+
+ /**
+ * Discover all seeders.
+ *
+ * @return array Ordered list of seeder class names
+ */
+ protected function discoverSeeders(): array
+ {
+ // Check if auto-discovery is enabled
+ if (! $this->shouldAutoDiscover()) {
+ return $this->getManualSeeders();
+ }
+
+ $discovery = $this->getDiscovery();
+
+ return $discovery->discover();
+ }
+
+ /**
+ * Get manually registered seeders.
+ *
+ * @return array
+ */
+ protected function getManualSeeders(): array
+ {
+ $registry = $this->getRegistry();
+
+ return $registry->getOrdered();
+ }
+
+ /**
+ * Get the seeder discovery instance.
+ */
+ protected function getDiscovery(): SeederDiscovery
+ {
+ if ($this->discovery === null) {
+ $this->discovery = new SeederDiscovery(
+ $this->getSeederPaths(),
+ $this->getExcludedSeeders()
+ );
+ }
+
+ return $this->discovery;
+ }
+
+ /**
+ * Get the seeder registry instance.
+ */
+ protected function getRegistry(): SeederRegistry
+ {
+ if ($this->registry === null) {
+ $this->registry = new SeederRegistry;
+ $this->registerSeeders($this->registry);
+ }
+
+ return $this->registry;
+ }
+
+ /**
+ * Register seeders manually when auto-discovery is disabled.
+ *
+ * Override this method in subclasses to add seeders.
+ *
+ * @param SeederRegistry $registry The registry to add seeders to
+ */
+ protected function registerSeeders(SeederRegistry $registry): void
+ {
+ // Override in subclasses
+ }
+
+ /**
+ * Get paths to scan for seeders.
+ *
+ * Override this method to customize seeder paths.
+ *
+ * @return array
+ */
+ protected function getSeederPaths(): array
+ {
+ // Use config if available, otherwise use defaults
+ $config = config('core.seeders.paths');
+
+ if (is_array($config) && ! empty($config)) {
+ return $config;
+ }
+
+ return [
+ app_path('Core'),
+ app_path('Mod'),
+ app_path('Website'),
+ ];
+ }
+
+ /**
+ * Get seeders to exclude.
+ *
+ * Override this method to customize excluded seeders.
+ *
+ * @return array
+ */
+ protected function getExcludedSeeders(): array
+ {
+ return config('core.seeders.exclude', []);
+ }
+
+ /**
+ * Check if auto-discovery should be used.
+ */
+ protected function shouldAutoDiscover(): bool
+ {
+ if (! $this->autoDiscover) {
+ return false;
+ }
+
+ return config('core.seeders.auto_discover', true);
+ }
+
+ /**
+ * Apply the --exclude filter.
+ *
+ * @param array $seeders List of seeder classes
+ * @return array Filtered list
+ */
+ protected function applyExcludeFilter(array $seeders): array
+ {
+ $excludes = $this->getCommandOption('exclude');
+
+ if (empty($excludes)) {
+ return $seeders;
+ }
+
+ $excludePatterns = is_array($excludes) ? $excludes : [$excludes];
+
+ return array_filter($seeders, function ($seeder) use ($excludePatterns) {
+ foreach ($excludePatterns as $pattern) {
+ if ($this->matchesPattern($seeder, $pattern)) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Apply the --only filter.
+ *
+ * @param array