feat(webhooks): implement entitlement webhook management with delivery tracking and event handling
This commit is contained in:
parent
36f524cc5c
commit
62c23b7fe9
44 changed files with 4509 additions and 2304 deletions
|
|
@ -1,394 +0,0 @@
|
||||||
# Code Improvements Analysis
|
|
||||||
|
|
||||||
**Generated:** 2026-01-26
|
|
||||||
**Scope:** core-php and core-admin packages
|
|
||||||
**Focus:** Production-ready improvements for v1.0.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Found **12 high-impact improvements** across core-php and core-admin packages. These improvements focus on:
|
|
||||||
|
|
||||||
1. **Completing partial implementations** (ServiceDiscovery, SeederRegistry)
|
|
||||||
2. **Removing TODO comments** for clean v1.0.0 release
|
|
||||||
3. **Type safety improvements** (ConfigService)
|
|
||||||
4. **Test coverage gaps** (Services, Seeders)
|
|
||||||
5. **Performance optimizations** (Config caching)
|
|
||||||
|
|
||||||
**Total estimated effort:** 18-24 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High Priority Improvements
|
|
||||||
|
|
||||||
### 1. Complete ServiceDiscovery Implementation ⭐⭐⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/Service/ServiceDiscovery.php`
|
|
||||||
|
|
||||||
**Issue:** ServiceDiscovery is fully documented (752 lines!) but appears to be unused in the codebase. No services are actually implementing `ServiceDefinition`.
|
|
||||||
|
|
||||||
**Impact:** High - Core infrastructure for service registration and dependency resolution
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Create example service implementing `ServiceDefinition`
|
|
||||||
- [ ] Wire ServiceDiscovery into Boot/lifecycle
|
|
||||||
- [ ] Add test coverage for discovery process
|
|
||||||
- [ ] Document how modules register as services
|
|
||||||
- [ ] OR: Mark as experimental/future feature in docs
|
|
||||||
|
|
||||||
**Estimated effort:** 4-5 hours
|
|
||||||
|
|
||||||
**Code snippet:**
|
|
||||||
```php
|
|
||||||
// File shows comprehensive implementation but no usage:
|
|
||||||
class ServiceDiscovery
|
|
||||||
{
|
|
||||||
public function discover(): Collection { /* 752 lines */ }
|
|
||||||
public function validateDependencies(): array { /* ... */ }
|
|
||||||
public function getResolutionOrder(): Collection { /* ... */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// But grep shows no ServiceDefinition implementations in codebase
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Complete SeederRegistry Integration ⭐⭐⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/Database/Seeders/SeederRegistry.php`
|
|
||||||
|
|
||||||
**Issue:** SeederRegistry + SeederDiscovery exist but aren't integrated with Laravel's seeder system. The `CoreDatabaseSeeder` class exists but may not use these.
|
|
||||||
|
|
||||||
**Impact:** High - Critical for database setup
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Integrate SeederRegistry with `CoreDatabaseSeeder`
|
|
||||||
- [ ] Test seeder dependency resolution
|
|
||||||
- [ ] Add circular dependency detection tests
|
|
||||||
- [ ] Document seeder ordering in README
|
|
||||||
- [ ] Add `php artisan db:seed --class=CoreDatabaseSeeder` docs
|
|
||||||
|
|
||||||
**Estimated effort:** 3-4 hours
|
|
||||||
|
|
||||||
**Code snippet:**
|
|
||||||
```php
|
|
||||||
// SeederRegistry has full topological sort implementation
|
|
||||||
public function getOrdered(): array
|
|
||||||
{
|
|
||||||
$discovery = new class extends SeederDiscovery {
|
|
||||||
public function setSeeders(array $seeders): void { /* ... */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
return $discovery->discover();
|
|
||||||
}
|
|
||||||
|
|
||||||
// But TODO indicates this is incomplete
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Remove UserStatsService TODO Comments ⭐⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Mod/Tenant/Services/UserStatsService.php`
|
|
||||||
|
|
||||||
**Issue:** 6 TODO comments for features that won't exist in v1.0.0:
|
|
||||||
- Social accounts (line 83)
|
|
||||||
- Scheduled posts (line 87)
|
|
||||||
- Storage tracking (line 92)
|
|
||||||
- Social account checks (line 165)
|
|
||||||
- Bio page checks (line 170)
|
|
||||||
- Activity logging (line 218)
|
|
||||||
|
|
||||||
**Impact:** Medium - Confusing for contributors, looks unfinished
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Remove TODOs and replace with `// Future: ...` comments
|
|
||||||
- [ ] Add docblock explaining these are planned v1.1+ features
|
|
||||||
- [ ] Update service stats methods to return placeholder data cleanly
|
|
||||||
- [ ] Document feature roadmap in separate file
|
|
||||||
|
|
||||||
**Estimated effort:** 1 hour
|
|
||||||
|
|
||||||
**Code snippet:**
|
|
||||||
```php
|
|
||||||
// Current:
|
|
||||||
// TODO: Implement when social accounts are linked
|
|
||||||
// $socialAccountCount = ...
|
|
||||||
|
|
||||||
// Improved:
|
|
||||||
// Future (v1.1+): Track social accounts across workspaces
|
|
||||||
// Will be implemented when Mod\Social integration is complete
|
|
||||||
$limits['social_accounts']['used'] = 0; // Placeholder until v1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Remove 2FA TODO Comments from Settings Modal ⭐⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-admin/src/Website/Hub/View/Modal/Admin/Settings.php`
|
|
||||||
|
|
||||||
**Issue:** 5 identical TODO comments: `// TODO: Implement native 2FA - currently disabled`
|
|
||||||
|
|
||||||
**Impact:** Medium - Duplicate comments, confusing state
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Remove duplicate TODO comments
|
|
||||||
- [ ] Add single docblock at class level explaining 2FA status
|
|
||||||
- [ ] Update feature flag logic with clear comment
|
|
||||||
- [ ] Document 2FA roadmap in ROADMAP.md (already exists)
|
|
||||||
|
|
||||||
**Estimated effort:** 30 minutes
|
|
||||||
|
|
||||||
**Code snippet:**
|
|
||||||
```php
|
|
||||||
// Current: 5x duplicate TODO comments
|
|
||||||
|
|
||||||
// Improved:
|
|
||||||
/**
|
|
||||||
* Settings Modal
|
|
||||||
*
|
|
||||||
* Two-Factor Authentication:
|
|
||||||
* Native 2FA is planned for v1.2 (see ROADMAP.md).
|
|
||||||
* Currently checks config('social.features.two_factor_auth') flag.
|
|
||||||
* When enabled, integrates with Laravel Fortify.
|
|
||||||
*/
|
|
||||||
class Settings extends Component
|
|
||||||
{
|
|
||||||
// Feature flags - 2FA via config flag
|
|
||||||
public bool $isTwoFactorEnabled = false;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. ConfigService Type Safety Improvements ⭐⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/Config/ConfigService.php`
|
|
||||||
|
|
||||||
**Issue:** 25+ public methods with complex signatures, some using `mixed` types. Could benefit from stricter typing and return type hints.
|
|
||||||
|
|
||||||
**Impact:** Medium - Better IDE support and type safety
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Add stricter return types where possible
|
|
||||||
- [ ] Use union types (e.g., `string|int|bool|array`)
|
|
||||||
- [ ] Add @template PHPDoc for generic methods
|
|
||||||
- [ ] Add PHPStan level 5 annotations
|
|
||||||
- [ ] Test with PHPStan --level=5
|
|
||||||
|
|
||||||
**Estimated effort:** 2-3 hours
|
|
||||||
|
|
||||||
**Code snippet:**
|
|
||||||
```php
|
|
||||||
// Current:
|
|
||||||
public function get(string $key, mixed $default = null): mixed
|
|
||||||
|
|
||||||
// Improved with generics:
|
|
||||||
/**
|
|
||||||
* @template T
|
|
||||||
* @param T $default
|
|
||||||
* @return T
|
|
||||||
*/
|
|
||||||
public function get(string $key, mixed $default = null): mixed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Add Missing Service Tests ⭐⭐
|
|
||||||
|
|
||||||
**Issue:** Several services lack dedicated test files:
|
|
||||||
- `ActivityLogService` - no test file
|
|
||||||
- `BlocklistService` - has test but inline (should be in Tests/)
|
|
||||||
- `CspNonceService` - no tests
|
|
||||||
- `SchemaBuilderService` - no tests
|
|
||||||
|
|
||||||
**Impact:** Medium - Test coverage gaps
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Create `ActivityLogServiceTest.php`
|
|
||||||
- [ ] Move `BlocklistServiceTest` to proper location
|
|
||||||
- [ ] Create `CspNonceServiceTest.php`
|
|
||||||
- [ ] Create `SchemaBuilderServiceTest.php`
|
|
||||||
- [ ] Add integration tests for service lifecycle
|
|
||||||
|
|
||||||
**Estimated effort:** 4-5 hours
|
|
||||||
|
|
||||||
**Files to create:**
|
|
||||||
```
|
|
||||||
packages/core-php/src/Core/Activity/Tests/Unit/ActivityLogServiceTest.php
|
|
||||||
packages/core-php/src/Core/Headers/Tests/Unit/CspNonceServiceTest.php
|
|
||||||
packages/core-php/src/Core/Seo/Tests/Unit/SchemaBuilderServiceTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medium Priority Improvements
|
|
||||||
|
|
||||||
### 7. Optimize Config Caching ⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/Config/ConfigService.php`
|
|
||||||
|
|
||||||
**Issue:** Config resolution hits database frequently. Could use tiered caching (memory → Redis → DB).
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Profile config query performance
|
|
||||||
- [ ] Implement request-level memoization cache
|
|
||||||
- [ ] Add Redis cache layer with TTL
|
|
||||||
- [ ] Add config warmup artisan command
|
|
||||||
- [ ] Document cache strategy
|
|
||||||
|
|
||||||
**Estimated effort:** 3-4 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Add ServiceDiscovery Artisan Commands ⭐
|
|
||||||
|
|
||||||
**Issue:** No CLI tooling for service management
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Create `php artisan services:list` command
|
|
||||||
- [ ] Create `php artisan services:validate` command
|
|
||||||
- [ ] Create `php artisan services:cache` command
|
|
||||||
- [ ] Show dependency tree visualization
|
|
||||||
- [ ] Add JSON export option
|
|
||||||
|
|
||||||
**Estimated effort:** 2-3 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. Extract Locale/Timezone Lists to Config ⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Mod/Tenant/Services/UserStatsService.php`
|
|
||||||
|
|
||||||
**Issue:** Hardcoded locale/timezone lists in service methods
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Move to `config/locales.php`
|
|
||||||
- [ ] Move to `config/timezones.php`
|
|
||||||
- [ ] Make extensible via config
|
|
||||||
- [ ] Add `php artisan locales:update` command
|
|
||||||
- [ ] Support custom locale additions
|
|
||||||
|
|
||||||
**Estimated effort:** 1-2 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. Add MakePlugCommand Template Validation ⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/Console/Commands/MakePlugCommand.php`
|
|
||||||
|
|
||||||
**Issue:** TODO comments are intentional templates but could be validated
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Add `--validate` flag to check generated code
|
|
||||||
- [ ] Warn if TODOs remain after generation
|
|
||||||
- [ ] Add completion checklist after generation
|
|
||||||
- [ ] Create interactive setup wizard option
|
|
||||||
- [ ] Add `php artisan make:plug --example` with filled example
|
|
||||||
|
|
||||||
**Estimated effort:** 2-3 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low Priority Improvements
|
|
||||||
|
|
||||||
### 11. Document RELEASE-BLOCKERS Status ⭐
|
|
||||||
|
|
||||||
**File:** `packages/core-php/src/Core/RELEASE-BLOCKERS.md`
|
|
||||||
|
|
||||||
**Issue:** File references TODOs as blockers but most are resolved
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Review and update blocker status
|
|
||||||
- [ ] Move resolved items to completed section
|
|
||||||
- [ ] Archive or delete if no longer relevant
|
|
||||||
- [ ] Link to TODO.md for tracking
|
|
||||||
|
|
||||||
**Estimated effort:** 30 minutes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. Standardize Service Naming ⭐
|
|
||||||
|
|
||||||
**Issue:** Inconsistent service class naming:
|
|
||||||
- `ActivityLogService` ✓
|
|
||||||
- `UserStatsService` ✓
|
|
||||||
- `CspNonceService` ✓
|
|
||||||
- `RedirectService` ✓
|
|
||||||
- BUT: `ServiceOgImageService` ❌ (should be `OgImageService`)
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- [ ] Rename `ServiceOgImageService` → `OgImageService`
|
|
||||||
- [ ] Update imports and references
|
|
||||||
- [ ] Add naming convention to CONTRIBUTING.md
|
|
||||||
- [ ] Check for other naming inconsistencies
|
|
||||||
|
|
||||||
**Estimated effort:** 1 hour
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality Metrics
|
|
||||||
|
|
||||||
**Current State:**
|
|
||||||
- ✅ Services: 33 service classes found
|
|
||||||
- ✅ Documentation: Excellent (752-line ServiceDiscovery doc!)
|
|
||||||
- ⚠️ Test Coverage: Gaps in service tests
|
|
||||||
- ⚠️ TODO Comments: 10+ production TODOs
|
|
||||||
- ⚠️ Type Safety: Good but could be stricter
|
|
||||||
|
|
||||||
**After Improvements:**
|
|
||||||
- ✅ Zero production TODO comments
|
|
||||||
- ✅ All services have tests (80%+ coverage)
|
|
||||||
- ✅ ServiceDiscovery fully integrated OR documented as future
|
|
||||||
- ✅ SeederRegistry integrated with database setup
|
|
||||||
- ✅ Stricter type hints with generics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
### For v1.0.0 Release (Next 48 hours):
|
|
||||||
1. Remove TODO comments (#3, #4) - 1.5 hours
|
|
||||||
2. Document ServiceDiscovery status (#1) - 1 hour
|
|
||||||
3. Add critical service tests (#6) - 2 hours
|
|
||||||
4. Review RELEASE-BLOCKERS (#11) - 30 minutes
|
|
||||||
|
|
||||||
**Total: 5 hours for clean v1.0.0**
|
|
||||||
|
|
||||||
### For v1.1 (Post-release):
|
|
||||||
1. Complete ServiceDiscovery integration (#1) - 4 hours
|
|
||||||
2. Complete SeederRegistry integration (#2) - 3 hours
|
|
||||||
3. Config caching optimization (#7) - 3 hours
|
|
||||||
4. Type safety improvements (#5) - 2 hours
|
|
||||||
|
|
||||||
**Total: 12 hours for v1.1 features**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### Immediate (Before v1.0.0 release):
|
|
||||||
✅ **Remove all TODO comments** - Replace with "Future:" or remove entirely
|
|
||||||
✅ **Add service test coverage** - At least smoke tests for critical services
|
|
||||||
✅ **Document incomplete features** - Clear roadmap for ServiceDiscovery/SeederRegistry
|
|
||||||
|
|
||||||
### Short-term (v1.1):
|
|
||||||
🔨 **Complete ServiceDiscovery** - Integrate or document as experimental
|
|
||||||
🔨 **Seeder dependency resolution** - Wire into CoreDatabaseSeeder
|
|
||||||
🔨 **Config caching** - Significant performance win
|
|
||||||
|
|
||||||
### Long-term (v1.2+):
|
|
||||||
📚 **Service CLI tools** - Better DX for service management
|
|
||||||
📚 **Type safety audit** - PHPStan level 8
|
|
||||||
📚 **Performance profiling** - Benchmark all services
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **ServiceDiscovery**: Incredibly well-documented but appears unused. Needs integration OR documentation as future feature.
|
|
||||||
- **SeederRegistry**: Has topological sort implemented but not wired up. High value once integrated.
|
|
||||||
- **UserStatsService**: TODOs are for v1.1+ features - should document this clearly.
|
|
||||||
- **Config System**: Very comprehensive - caching would be high-value optimization.
|
|
||||||
|
|
||||||
**Overall Assessment:** Code quality is high. Main improvements are completing integrations and removing TODOs for clean v1.0.0 release.
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
# Using `php artisan core:new`
|
|
||||||
|
|
||||||
The `core:new` command scaffolds a new Core PHP Framework project, similar to `laravel new`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new project
|
|
||||||
php artisan core:new my-project
|
|
||||||
|
|
||||||
# With custom template
|
|
||||||
php artisan core:new my-api --template=host-uk/core-api-template
|
|
||||||
|
|
||||||
# Skip installation (manual setup)
|
|
||||||
php artisan core:new my-project --no-install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new {name}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arguments:**
|
|
||||||
- `name` - Project directory name (required)
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--template=` - GitHub template repository (default: `host-uk/core-template`)
|
|
||||||
- `--branch=` - Template branch to use (default: `main`)
|
|
||||||
- `--no-install` - Skip `composer install` and `core:install`
|
|
||||||
- `--dev` - Install with `--prefer-source` for development
|
|
||||||
- `--force` - Overwrite existing directory
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### 1. Standard Project
|
|
||||||
|
|
||||||
Creates a full-stack application with all Core packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-app
|
|
||||||
cd my-app
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
**Includes:**
|
|
||||||
- Core framework
|
|
||||||
- Admin panel (Livewire + Flux)
|
|
||||||
- REST API (scopes, webhooks, OpenAPI)
|
|
||||||
- MCP tools for AI agents
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. API-Only Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-api \
|
|
||||||
--template=host-uk/core-api-template
|
|
||||||
```
|
|
||||||
|
|
||||||
**Includes:**
|
|
||||||
- Core framework
|
|
||||||
- core-api package
|
|
||||||
- Minimal routes (API only)
|
|
||||||
- No frontend dependencies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Admin Panel Only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-admin \
|
|
||||||
--template=host-uk/core-admin-template
|
|
||||||
```
|
|
||||||
|
|
||||||
**Includes:**
|
|
||||||
- Core framework
|
|
||||||
- core-admin package
|
|
||||||
- Livewire + Flux UI
|
|
||||||
- Auth scaffolding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Custom Template
|
|
||||||
|
|
||||||
Use your own or community templates:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Your own template
|
|
||||||
php artisan core:new my-project \
|
|
||||||
--template=my-company/core-custom
|
|
||||||
|
|
||||||
# Community template
|
|
||||||
php artisan core:new my-blog \
|
|
||||||
--template=johndoe/core-blog-starter
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Specific Version
|
|
||||||
|
|
||||||
Lock to a specific template version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project \
|
|
||||||
--template=host-uk/core-template \
|
|
||||||
--branch=v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Manual Setup
|
|
||||||
|
|
||||||
Create project but skip automated setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project --no-install
|
|
||||||
|
|
||||||
cd my-project
|
|
||||||
composer install
|
|
||||||
cp .env.example .env
|
|
||||||
php artisan key:generate
|
|
||||||
php artisan core:install
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful when you want to:
|
|
||||||
- Review dependencies before installing
|
|
||||||
- Customize composer.json first
|
|
||||||
- Set up .env manually
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. Development Mode
|
|
||||||
|
|
||||||
Install packages with `--prefer-source` for contributing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Clones packages as git repos instead of downloading archives.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What It Does
|
|
||||||
|
|
||||||
When you run `php artisan core:new my-project`, it:
|
|
||||||
|
|
||||||
1. **Clones template** from GitHub
|
|
||||||
2. **Removes .git** to make it a fresh repo
|
|
||||||
3. **Updates composer.json** with your project name
|
|
||||||
4. **Installs dependencies** via Composer
|
|
||||||
5. **Runs core:install** to configure the app
|
|
||||||
6. **Initializes git** with initial commit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
After creation, your project will have:
|
|
||||||
|
|
||||||
```
|
|
||||||
my-project/
|
|
||||||
├── app/
|
|
||||||
│ ├── Console/
|
|
||||||
│ ├── Http/
|
|
||||||
│ ├── Models/
|
|
||||||
│ └── Mod/ # Your modules go here
|
|
||||||
├── bootstrap/
|
|
||||||
│ └── app.php # Core packages registered
|
|
||||||
├── config/
|
|
||||||
│ └── core.php # Core framework config
|
|
||||||
├── database/
|
|
||||||
│ ├── migrations/ # Core + your migrations
|
|
||||||
│ └── seeders/
|
|
||||||
├── routes/
|
|
||||||
│ ├── api.php # API routes (via core-api)
|
|
||||||
│ ├── console.php # Artisan commands
|
|
||||||
│ └── web.php # Web routes
|
|
||||||
├── .env
|
|
||||||
├── composer.json # Core packages required
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps After Creation
|
|
||||||
|
|
||||||
### 1. Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd my-project
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit: http://localhost:8000
|
|
||||||
|
|
||||||
### 2. Access Admin Panel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create an admin user
|
|
||||||
php artisan make:user admin@example.com --admin
|
|
||||||
|
|
||||||
# Visit admin panel
|
|
||||||
open http://localhost:8000/admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Create a Module
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full-featured module
|
|
||||||
php artisan make:mod Blog --all
|
|
||||||
|
|
||||||
# Specific features
|
|
||||||
php artisan make:mod Shop --web --api --admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Configure API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate API key
|
|
||||||
php artisan api:key-create "My App" --scopes=posts:read,posts:write
|
|
||||||
|
|
||||||
# View OpenAPI docs
|
|
||||||
open http://localhost:8000/api/docs
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Enable MCP Tools
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List available tools
|
|
||||||
php artisan mcp:list
|
|
||||||
|
|
||||||
# Test a tool
|
|
||||||
php artisan mcp:test query_database
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Template Not Found
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Failed to clone template
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:** Verify template exists on GitHub:
|
|
||||||
```bash
|
|
||||||
# Check if template is public
|
|
||||||
curl -I https://github.com/host-uk/core-template
|
|
||||||
|
|
||||||
# Use HTTPS URL explicitly
|
|
||||||
php artisan core:new my-project \
|
|
||||||
--template=https://github.com/host-uk/core-template.git
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Composer Install Fails
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Composer install failed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:** Install manually:
|
|
||||||
```bash
|
|
||||||
cd my-project
|
|
||||||
composer install --no-interaction
|
|
||||||
php artisan core:install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Directory Already Exists
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Directory [my-project] already exists!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:** Use `--force` or choose different name:
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project --force
|
|
||||||
# or
|
|
||||||
php artisan core:new my-project-v2
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Git Not Found
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: git command not found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:** Install Git:
|
|
||||||
```bash
|
|
||||||
# macOS
|
|
||||||
brew install git
|
|
||||||
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install git
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
# Download from https://git-scm.com
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Template Repositories
|
|
||||||
|
|
||||||
### Official Templates
|
|
||||||
|
|
||||||
| Template | Purpose | Command |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `host-uk/core-template` | Full-stack (default) | `php artisan core:new app` |
|
|
||||||
| `host-uk/core-api-template` | API-only | `--template=host-uk/core-api-template` |
|
|
||||||
| `host-uk/core-admin-template` | Admin panel only | `--template=host-uk/core-admin-template` |
|
|
||||||
| `host-uk/core-saas-template` | SaaS starter | `--template=host-uk/core-saas-template` |
|
|
||||||
|
|
||||||
### Community Templates
|
|
||||||
|
|
||||||
Browse templates: https://github.com/topics/core-php-template
|
|
||||||
|
|
||||||
Create your own: See `CREATING-TEMPLATE-REPO.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
After creation, update `.env`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# App Settings
|
|
||||||
APP_NAME="My Project"
|
|
||||||
APP_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_CONNECTION=sqlite
|
|
||||||
DB_DATABASE=database/database.sqlite
|
|
||||||
|
|
||||||
# Core Framework
|
|
||||||
CORE_CACHE_DISCOVERY=true
|
|
||||||
|
|
||||||
# Optional: CDN
|
|
||||||
CDN_ENABLED=false
|
|
||||||
CDN_DRIVER=bunny
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison to Other Tools
|
|
||||||
|
|
||||||
### vs `laravel new`
|
|
||||||
|
|
||||||
**Laravel New:**
|
|
||||||
```bash
|
|
||||||
laravel new my-project
|
|
||||||
# Creates: Basic Laravel app
|
|
||||||
```
|
|
||||||
|
|
||||||
**Core New:**
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
# Creates: Laravel + Core packages pre-configured
|
|
||||||
# Admin panel, API, MCP tools ready to use
|
|
||||||
```
|
|
||||||
|
|
||||||
### vs `composer create-project`
|
|
||||||
|
|
||||||
**Composer:**
|
|
||||||
```bash
|
|
||||||
composer create-project laravel/laravel my-project
|
|
||||||
composer require host-uk/core host-uk/core-admin ...
|
|
||||||
# Manual: Update bootstrap/app.php, config files, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Core New:**
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
# Everything configured automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
### Create Your Own Template
|
|
||||||
|
|
||||||
1. Fork `host-uk/core-template`
|
|
||||||
2. Customize for your use case
|
|
||||||
3. Enable "Template repository" on GitHub
|
|
||||||
4. Share with the community!
|
|
||||||
|
|
||||||
See: `CREATING-TEMPLATE-REPO.md` for full guide
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: Can I use this in production?**
|
|
||||||
Yes! The template creates production-ready applications.
|
|
||||||
|
|
||||||
**Q: How do I update Core packages?**
|
|
||||||
```bash
|
|
||||||
composer update host-uk/core-*
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: Can I create a template without GitHub?**
|
|
||||||
Currently requires GitHub, but you can specify any git URL:
|
|
||||||
```bash
|
|
||||||
--template=https://gitlab.com/my-org/core-template.git
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: Does it work with Laravel Sail?**
|
|
||||||
Yes! After creation, add Sail:
|
|
||||||
```bash
|
|
||||||
cd my-project
|
|
||||||
php artisan sail:install
|
|
||||||
./vendor/bin/sail up
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: Can I customize the generated project?**
|
|
||||||
Absolutely! After creation, it's your project. Modify anything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- **Documentation:** https://github.com/host-uk/core-php
|
|
||||||
- **Issues:** https://github.com/host-uk/core-template/issues
|
|
||||||
- **Discussions:** https://github.com/host-uk/core-php/discussions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy coding with Core PHP Framework!** 🚀
|
|
||||||
|
|
@ -1,604 +0,0 @@
|
||||||
# Creating the Core PHP Framework Template Repository
|
|
||||||
|
|
||||||
This guide explains how to create the `host-uk/core-template` GitHub template repository that `php artisan core:new` will use to scaffold new projects.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The template repository is a minimal Laravel application pre-configured with Core PHP Framework packages. Users run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
```
|
|
||||||
|
|
||||||
This clones the template, configures it, and installs dependencies automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
host-uk/core-template/
|
|
||||||
├── app/
|
|
||||||
│ ├── Console/
|
|
||||||
│ ├── Http/
|
|
||||||
│ ├── Models/
|
|
||||||
│ └── Providers/
|
|
||||||
├── bootstrap/
|
|
||||||
│ └── app.php # Core packages registered here
|
|
||||||
├── config/
|
|
||||||
│ ├── app.php
|
|
||||||
│ ├── database.php
|
|
||||||
│ └── core.php # Core framework config
|
|
||||||
├── database/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ └── seeders/
|
|
||||||
├── public/
|
|
||||||
├── resources/
|
|
||||||
│ ├── views/
|
|
||||||
│ └── css/
|
|
||||||
├── routes/
|
|
||||||
│ ├── api.php
|
|
||||||
│ ├── console.php
|
|
||||||
│ └── web.php
|
|
||||||
├── storage/
|
|
||||||
├── tests/
|
|
||||||
├── .env.example
|
|
||||||
├── .gitignore
|
|
||||||
├── composer.json # Pre-configured with Core packages
|
|
||||||
├── package.json
|
|
||||||
├── phpunit.xml
|
|
||||||
├── README.md
|
|
||||||
└── vite.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Create Base Laravel App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create fresh Laravel 12 app
|
|
||||||
composer create-project laravel/laravel core-template
|
|
||||||
cd core-template
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: Configure composer.json
|
|
||||||
|
|
||||||
Update `composer.json` to require Core PHP packages:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "host-uk/core-template",
|
|
||||||
"type": "project",
|
|
||||||
"description": "Core PHP Framework - Project Template",
|
|
||||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
|
||||||
"license": "EUPL-1.2",
|
|
||||||
"require": {
|
|
||||||
"php": "^8.2",
|
|
||||||
"laravel/framework": "^12.0",
|
|
||||||
"laravel/tinker": "^2.10",
|
|
||||||
"livewire/flux": "^2.0",
|
|
||||||
"livewire/flux-pro": "^2.10",
|
|
||||||
"livewire/livewire": "^3.0",
|
|
||||||
"host-uk/core": "^1.0",
|
|
||||||
"host-uk/core-admin": "^1.0",
|
|
||||||
"host-uk/core-api": "^1.0",
|
|
||||||
"host-uk/core-mcp": "^1.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"fakerphp/faker": "^1.23",
|
|
||||||
"laravel/pail": "^1.2",
|
|
||||||
"laravel/pint": "^1.18",
|
|
||||||
"laravel/sail": "^1.41",
|
|
||||||
"mockery/mockery": "^1.6",
|
|
||||||
"nunomaduro/collision": "^8.6",
|
|
||||||
"phpunit/phpunit": "^11.5"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "app/",
|
|
||||||
"Website\\": "app/Website/",
|
|
||||||
"Database\\Factories\\": "database/factories/",
|
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repositories": [
|
|
||||||
{
|
|
||||||
"name": "flux-pro",
|
|
||||||
"type": "composer",
|
|
||||||
"url": "https://composer.fluxui.dev"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"post-autoload-dump": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
|
||||||
"@php artisan package:discover --ansi"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
|
||||||
],
|
|
||||||
"post-root-package-install": [
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
|
||||||
],
|
|
||||||
"post-create-project-cmd": [
|
|
||||||
"@php artisan key:generate --ansi",
|
|
||||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
|
||||||
"@php artisan migrate --graceful --ansi"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"dont-discover": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"prefer-stable": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Update bootstrap/app.php
|
|
||||||
|
|
||||||
Register Core PHP packages:
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withProviders([
|
|
||||||
// Core PHP Framework Packages
|
|
||||||
Core\CoreServiceProvider::class,
|
|
||||||
Core\Mod\Admin\Boot::class,
|
|
||||||
Core\Mod\Api\Boot::class,
|
|
||||||
Core\Mod\Mcp\Boot::class,
|
|
||||||
])
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
api: __DIR__.'/../routes/api.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
|
||||||
//
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
|
||||||
//
|
|
||||||
})->create();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4: Create config/core.php
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Core PHP Framework Configuration
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
'module_paths' => [
|
|
||||||
base_path('packages/core-php/src/Mod'),
|
|
||||||
base_path('packages/core-php/src/Core'),
|
|
||||||
base_path('app/Mod'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'services' => [
|
|
||||||
'cache_discovery' => env('CORE_CACHE_DISCOVERY', true),
|
|
||||||
],
|
|
||||||
|
|
||||||
'cdn' => [
|
|
||||||
'enabled' => env('CDN_ENABLED', false),
|
|
||||||
'driver' => env('CDN_DRIVER', 'bunny'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5: Update .env.example
|
|
||||||
|
|
||||||
Add Core PHP specific variables:
|
|
||||||
|
|
||||||
```env
|
|
||||||
APP_NAME="Core PHP App"
|
|
||||||
APP_ENV=local
|
|
||||||
APP_KEY=
|
|
||||||
APP_DEBUG=true
|
|
||||||
APP_TIMEZONE=UTC
|
|
||||||
APP_URL=http://localhost
|
|
||||||
|
|
||||||
APP_LOCALE=en_GB
|
|
||||||
APP_FALLBACK_LOCALE=en_GB
|
|
||||||
APP_FAKER_LOCALE=en_GB
|
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
|
||||||
# DB_HOST=127.0.0.1
|
|
||||||
# DB_PORT=3306
|
|
||||||
# DB_DATABASE=core
|
|
||||||
# DB_USERNAME=root
|
|
||||||
# DB_PASSWORD=
|
|
||||||
|
|
||||||
# Core PHP Framework
|
|
||||||
CORE_CACHE_DISCOVERY=true
|
|
||||||
|
|
||||||
# CDN Configuration
|
|
||||||
CDN_ENABLED=false
|
|
||||||
CDN_DRIVER=bunny
|
|
||||||
BUNNYCDN_API_KEY=
|
|
||||||
BUNNYCDN_STORAGE_ZONE=
|
|
||||||
BUNNYCDN_PULL_ZONE=
|
|
||||||
|
|
||||||
# Flux Pro (optional)
|
|
||||||
FLUX_LICENSE_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6: Create README.md
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Core PHP Framework Project
|
|
||||||
|
|
||||||
A modular monolith Laravel application built with Core PHP Framework.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ **Core Framework** - Event-driven module system with lazy loading
|
|
||||||
✅ **Admin Panel** - Livewire-powered admin interface with Flux UI
|
|
||||||
✅ **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs
|
|
||||||
✅ **MCP Tools** - Model Context Protocol for AI agent integration
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From Template (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using the core:new command
|
|
||||||
php artisan core:new my-project
|
|
||||||
|
|
||||||
# Or manually clone
|
|
||||||
git clone https://github.com/host-uk/core-template.git my-project
|
|
||||||
cd my-project
|
|
||||||
composer install
|
|
||||||
php artisan core:install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- PHP 8.2+
|
|
||||||
- Composer 2.x
|
|
||||||
- SQLite (default) or MySQL/PostgreSQL
|
|
||||||
- Node.js 18+ (for frontend assets)
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Install dependencies
|
|
||||||
composer install
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 2. Configure environment
|
|
||||||
cp .env.example .env
|
|
||||||
php artisan key:generate
|
|
||||||
|
|
||||||
# 3. Set up database
|
|
||||||
touch database/database.sqlite
|
|
||||||
php artisan migrate
|
|
||||||
|
|
||||||
# 4. Start development server
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit: http://localhost:8000
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── Mod/ # Your custom modules
|
|
||||||
├── Website/ # Multi-site website modules
|
|
||||||
└── Providers/ # Laravel service providers
|
|
||||||
|
|
||||||
config/
|
|
||||||
└── core.php # Core framework configuration
|
|
||||||
|
|
||||||
routes/
|
|
||||||
├── web.php # Public web routes
|
|
||||||
├── api.php # REST API routes (via core-api)
|
|
||||||
└── console.php # Artisan commands
|
|
||||||
```
|
|
||||||
|
|
||||||
## Creating Modules
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new module with all features
|
|
||||||
php artisan make:mod Blog --all
|
|
||||||
|
|
||||||
# Create module with specific features
|
|
||||||
php artisan make:mod Shop --web --api --admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Modules follow the event-driven pattern:
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
namespace Mod\Blog;
|
|
||||||
|
|
||||||
class Boot
|
|
||||||
{
|
|
||||||
public static array $listens = [
|
|
||||||
WebRoutesRegistering::class => 'onWebRoutes',
|
|
||||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
|
||||||
AdminPanelBooting::class => 'onAdminPanel',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
|
||||||
{
|
|
||||||
$event->routes(fn() => require __DIR__.'/Routes/web.php');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Packages
|
|
||||||
|
|
||||||
- **host-uk/core** - Core framework components
|
|
||||||
- **host-uk/core-admin** - Admin panel with Livewire modals
|
|
||||||
- **host-uk/core-api** - REST API with scopes & webhooks
|
|
||||||
- **host-uk/core-mcp** - Model Context Protocol tools for AI
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Core PHP Framework](https://github.com/host-uk/core-php)
|
|
||||||
- [Admin Package](https://github.com/host-uk/core-admin)
|
|
||||||
- [API Package](https://github.com/host-uk/core-api)
|
|
||||||
- [MCP Package](https://github.com/host-uk/core-mcp)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
EUPL-1.2 (European Union Public Licence)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7: Add .gitattributes
|
|
||||||
|
|
||||||
```gitattributes
|
|
||||||
* text=auto
|
|
||||||
|
|
||||||
*.blade.php diff=html
|
|
||||||
*.css diff=css
|
|
||||||
*.html diff=html
|
|
||||||
*.md diff=markdown
|
|
||||||
*.php diff=php
|
|
||||||
|
|
||||||
/.github export-ignore
|
|
||||||
CHANGELOG.md export-ignore
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 8: Create GitHub Repository
|
|
||||||
|
|
||||||
### On GitHub:
|
|
||||||
|
|
||||||
1. **Create new repository**
|
|
||||||
- Name: `core-template`
|
|
||||||
- Description: "Core PHP Framework - Project Template"
|
|
||||||
- Public repository
|
|
||||||
- ✅ Check "Template repository"
|
|
||||||
|
|
||||||
2. **Push your code**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
git add .
|
|
||||||
git commit -m "Initial Core PHP Framework template"
|
|
||||||
git branch -M main
|
|
||||||
git remote add origin https://github.com/host-uk/core-template.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure template settings**
|
|
||||||
- Go to Settings → General
|
|
||||||
- Under "Template repository", enable checkbox
|
|
||||||
- Add topics: `laravel`, `core-php`, `modular-monolith`, `template`
|
|
||||||
|
|
||||||
4. **Create releases**
|
|
||||||
- Tag: `v1.0.0`
|
|
||||||
- Title: "Core PHP Framework Template v1.0.0"
|
|
||||||
- Include changelog
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 9: Test Template Creation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test the template works
|
|
||||||
php artisan core:new test-project
|
|
||||||
|
|
||||||
# Should create:
|
|
||||||
# - test-project/ directory
|
|
||||||
# - Run composer install
|
|
||||||
# - Run core:install
|
|
||||||
# - Initialize git repo
|
|
||||||
|
|
||||||
cd test-project
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Additional Template Variants
|
|
||||||
|
|
||||||
You can create specialized templates:
|
|
||||||
|
|
||||||
### API-Only Template
|
|
||||||
**Repository:** `host-uk/core-api-template`
|
|
||||||
**Usage:** `php artisan core:new my-api --template=host-uk/core-api-template`
|
|
||||||
|
|
||||||
Includes only:
|
|
||||||
- core
|
|
||||||
- core-api
|
|
||||||
- Minimal routes (API only)
|
|
||||||
|
|
||||||
### Admin-Only Template
|
|
||||||
**Repository:** `host-uk/core-admin-template`
|
|
||||||
**Usage:** `php artisan core:new my-admin --template=host-uk/core-admin-template`
|
|
||||||
|
|
||||||
Includes only:
|
|
||||||
- core
|
|
||||||
- core-admin
|
|
||||||
- Auth scaffolding
|
|
||||||
|
|
||||||
### SaaS Template
|
|
||||||
**Repository:** `host-uk/core-saas-template`
|
|
||||||
**Usage:** `php artisan core:new my-saas --template=host-uk/core-saas-template`
|
|
||||||
|
|
||||||
Includes:
|
|
||||||
- All core packages
|
|
||||||
- Multi-tenancy pre-configured
|
|
||||||
- Billing integration stubs
|
|
||||||
- Feature flags
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updating the Template
|
|
||||||
|
|
||||||
When you release new core package versions:
|
|
||||||
|
|
||||||
1. Update `composer.json` with new version constraints
|
|
||||||
2. Update `.env.example` with new config options
|
|
||||||
3. Update `README.md` with new features
|
|
||||||
4. Tag a new release: `v1.1.0`, `v1.2.0`, etc.
|
|
||||||
|
|
||||||
Users can specify template versions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project --template=host-uk/core-template --branch=v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GitHub Actions (Optional)
|
|
||||||
|
|
||||||
Add `.github/workflows/test-template.yml` to test template on every commit:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Test Template
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: 8.2
|
|
||||||
extensions: sqlite3
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist
|
|
||||||
|
|
||||||
- name: Copy .env
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate Key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Create Database
|
|
||||||
run: touch database/database.sqlite
|
|
||||||
|
|
||||||
- name: Run Migrations
|
|
||||||
run: php artisan migrate --force
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
run: php artisan test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Regular Updates
|
|
||||||
|
|
||||||
- **Monthly:** Update Laravel & core package versions
|
|
||||||
- **Security:** Apply security patches immediately
|
|
||||||
- **Testing:** Test template creation works after updates
|
|
||||||
|
|
||||||
### Community Templates
|
|
||||||
|
|
||||||
Encourage community to create their own templates:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Community members can create templates like:
|
|
||||||
php artisan core:new my-blog --template=johndoe/core-blog-template
|
|
||||||
php artisan core:new my-shop --template=acme/core-ecommerce
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues with the template:
|
|
||||||
- **GitHub Issues:** https://github.com/host-uk/core-template/issues
|
|
||||||
- **Discussions:** https://github.com/host-uk/core-php/discussions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
Before publishing the template:
|
|
||||||
|
|
||||||
- [ ] All core packages install without errors
|
|
||||||
- [ ] `php artisan core:install` runs successfully
|
|
||||||
- [ ] Database migrations work
|
|
||||||
- [ ] `php artisan serve` starts server
|
|
||||||
- [ ] Admin panel accessible at `/admin`
|
|
||||||
- [ ] API routes respond correctly
|
|
||||||
- [ ] MCP tools registered
|
|
||||||
- [ ] README.md is clear and helpful
|
|
||||||
- [ ] .env.example has all required variables
|
|
||||||
- [ ] Repository is marked as "Template repository"
|
|
||||||
- [ ] v1.0.0 release is tagged
|
|
||||||
- [ ] License file is included (EUPL-1.2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Template Ready!** 🚀
|
|
||||||
|
|
||||||
Users can now run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-awesome-project
|
|
||||||
```
|
|
||||||
|
|
||||||
And get a fully configured Core PHP Framework application in seconds.
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
# Session Summary - 2026-01-26
|
|
||||||
|
|
||||||
**Total Credits Used:** ~1.59 (from 1.95 remaining to 0.41)
|
|
||||||
**Duration:** Full session
|
|
||||||
**Focus Areas:** EPIC planning, code improvements analysis, project scaffolding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Major Deliverables
|
|
||||||
|
|
||||||
### 1. **Core DOM Component System EPIC** ✅
|
|
||||||
|
|
||||||
**File:** `packages/core-php/TODO.md` (lines 88-199)
|
|
||||||
|
|
||||||
Created comprehensive 8-phase EPIC for extending `<core:*>` Blade helpers to support HLCRF layouts:
|
|
||||||
|
|
||||||
**Phases:**
|
|
||||||
1. Architecture & Planning (2-3h)
|
|
||||||
2. Core DOM Components (4-6h) - `<core:header>`, `<core:content>`, etc.
|
|
||||||
3. Layout Containers (3-4h) - `<core:layout>`, `<core:page>`, `<core:dashboard>`
|
|
||||||
4. Semantic HTML Components (2-3h) - `<core:section>`, `<core:article>`
|
|
||||||
5. Component Composition (3-4h) - `<core:grid>`, `<core:stack>`, `<core:block>`
|
|
||||||
6. Integration & Testing (4-5h)
|
|
||||||
7. Documentation & Examples (3-4h)
|
|
||||||
8. Developer Experience (2-3h) - Artisan commands, validation
|
|
||||||
|
|
||||||
**Total Estimated Effort:** 23-32 hours
|
|
||||||
|
|
||||||
**Example Usage:**
|
|
||||||
```blade
|
|
||||||
<core:layout variant="HLCRF">
|
|
||||||
<core:header>
|
|
||||||
<nav>Navigation</nav>
|
|
||||||
</core:header>
|
|
||||||
|
|
||||||
<core:content>
|
|
||||||
<core:article>Main content</core:article>
|
|
||||||
</core:content>
|
|
||||||
|
|
||||||
<core:footer>
|
|
||||||
<p>© 2026</p>
|
|
||||||
</core:footer>
|
|
||||||
</core:layout>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact:** Dramatically improves DX for building HLCRF layouts with easy-to-remember Blade components instead of PHP API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. **Code Improvements Analysis** ✅
|
|
||||||
|
|
||||||
**File:** `CODE-IMPROVEMENTS.md` (470+ lines)
|
|
||||||
|
|
||||||
Comprehensive analysis of core-php and core-admin packages with **12 high-impact improvements**:
|
|
||||||
|
|
||||||
**High Priority (5 hours for v1.0.0):**
|
|
||||||
1. **ServiceDiscovery** - 752-line implementation appears unused, needs integration or documentation
|
|
||||||
2. **SeederRegistry** - Has topological sort but not wired into database seeding
|
|
||||||
3. **UserStatsService TODOs** - 6 TODO comments to clean up/document as v1.1+ features
|
|
||||||
4. **Settings Modal TODOs** - 5 duplicate 2FA comments to consolidate
|
|
||||||
5. **ConfigService Type Safety** - Stricter typing with generics
|
|
||||||
6. **Missing Service Tests** - ActivityLogService, CspNonceService, SchemaBuilderService
|
|
||||||
|
|
||||||
**Medium Priority:**
|
|
||||||
- Config caching optimization (3-4h)
|
|
||||||
- ServiceDiscovery artisan commands (2-3h)
|
|
||||||
- Locale/timezone extraction to config (1-2h)
|
|
||||||
|
|
||||||
**Findings:**
|
|
||||||
- Overall code quality is **excellent**
|
|
||||||
- Main improvements: Complete integrations, remove TODOs for clean v1.0.0
|
|
||||||
- ServiceDiscovery/SeederRegistry are well-documented but need wiring
|
|
||||||
|
|
||||||
**Quick Wins Identified:**
|
|
||||||
```markdown
|
|
||||||
## For v1.0.0 Release (5 hours):
|
|
||||||
1. Remove TODO comments (1.5h)
|
|
||||||
2. Document ServiceDiscovery status (1h)
|
|
||||||
3. Add critical service tests (2h)
|
|
||||||
4. Review RELEASE-BLOCKERS (30m)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. **`php artisan core:new` Scaffolding System** ✅
|
|
||||||
|
|
||||||
**Files Created:**
|
|
||||||
1. `packages/core-php/src/Core/Console/Commands/NewProjectCommand.php` (350+ lines)
|
|
||||||
2. `CREATING-TEMPLATE-REPO.md` (450+ lines)
|
|
||||||
3. `CORE-NEW-USAGE.md` (400+ lines)
|
|
||||||
4. `SUMMARY-CORE-NEW.md` (350+ lines)
|
|
||||||
|
|
||||||
**What It Does:**
|
|
||||||
|
|
||||||
Creates a Laravel-style project scaffolder for Core PHP Framework:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- ✅ Clones GitHub template repository (host-uk/core-template)
|
|
||||||
- ✅ Updates composer.json with project name
|
|
||||||
- ✅ Runs `composer install` automatically
|
|
||||||
- ✅ Executes `core:install` for setup
|
|
||||||
- ✅ Initializes fresh git repository
|
|
||||||
- ✅ Supports custom templates: `--template=user/repo`
|
|
||||||
- ✅ Version pinning: `--branch=v1.0.0`
|
|
||||||
- ✅ Development mode: `--dev`
|
|
||||||
- ✅ Force overwrite: `--force`
|
|
||||||
- ✅ Skip install: `--no-install`
|
|
||||||
- ✅ Dry-run mode: `--dry-run`
|
|
||||||
|
|
||||||
**User Flow:**
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-app
|
|
||||||
# Result: Production-ready app in < 2 minutes
|
|
||||||
cd my-app
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
**Integration:**
|
|
||||||
- ✅ Registered in `Core/Console/Boot.php`
|
|
||||||
- ✅ Added to `TODO.md` with checklist
|
|
||||||
- ✅ Complete documentation for users and maintainers
|
|
||||||
|
|
||||||
**Next Steps:**
|
|
||||||
1. Create `host-uk/core-template` GitHub repository (3-4h)
|
|
||||||
2. Enable "Template repository" setting
|
|
||||||
3. Test: `php artisan core:new test-project`
|
|
||||||
4. Include in v1.0.0 release announcement
|
|
||||||
|
|
||||||
**Impact:** Dramatically simplifies framework adoption. Users can scaffold projects in seconds instead of manual setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified/Created
|
|
||||||
|
|
||||||
### Created (7 files):
|
|
||||||
1. `/CODE-IMPROVEMENTS.md` - Analysis document (470 lines)
|
|
||||||
2. `/CREATING-TEMPLATE-REPO.md` - Template creation guide (450 lines)
|
|
||||||
3. `/CORE-NEW-USAGE.md` - User documentation (400 lines)
|
|
||||||
4. `/SUMMARY-CORE-NEW.md` - Implementation summary (350 lines)
|
|
||||||
5. `/packages/core-php/src/Core/Console/Commands/NewProjectCommand.php` (350 lines)
|
|
||||||
6. `/SESSION-SUMMARY.md` - This file
|
|
||||||
7. Plus updates to TODO.md
|
|
||||||
|
|
||||||
### Modified (2 files):
|
|
||||||
1. `packages/core-php/TODO.md` - Added DOM EPIC + GitHub template task
|
|
||||||
2. `packages/core-php/src/Core/Console/Boot.php` - Registered NewProjectCommand
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Insights
|
|
||||||
|
|
||||||
### 1. **ServiceDiscovery & SeederRegistry**
|
|
||||||
|
|
||||||
These are **incredibly well-documented** (752 lines for ServiceDiscovery!) but appear unused:
|
|
||||||
- No services implement `ServiceDefinition` interface
|
|
||||||
- Seeder dependency resolution not wired into `CoreDatabaseSeeder`
|
|
||||||
|
|
||||||
**Recommendation:** Either integrate before v1.0.0 or document as experimental/v1.1 feature.
|
|
||||||
|
|
||||||
### 2. **TODO Comments**
|
|
||||||
|
|
||||||
Found **10+ production TODOs** that should be cleaned up:
|
|
||||||
- UserStatsService: 6 TODOs for v1.1+ features (social accounts, storage tracking)
|
|
||||||
- Settings.php: 5 duplicate 2FA TODOs
|
|
||||||
- MakePlugCommand: Intentional template TODOs (acceptable)
|
|
||||||
|
|
||||||
**Quick fix:** Replace with `// Future (v1.1+):` comments or remove entirely.
|
|
||||||
|
|
||||||
### 3. **Test Coverage Gaps**
|
|
||||||
|
|
||||||
Several core services lack tests:
|
|
||||||
- ActivityLogService
|
|
||||||
- CspNonceService
|
|
||||||
- SchemaBuilderService
|
|
||||||
|
|
||||||
**Impact:** Medium priority - add smoke tests before v1.0.0.
|
|
||||||
|
|
||||||
### 4. **Framework Architecture is Solid**
|
|
||||||
|
|
||||||
The event-driven module system with lazy loading is well-implemented:
|
|
||||||
- Clean separation of concerns
|
|
||||||
- Excellent documentation
|
|
||||||
- Follows Laravel conventions
|
|
||||||
- Type safety is good (could be stricter with generics)
|
|
||||||
|
|
||||||
**Assessment:** Ready for v1.0.0 with minor cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations for v1.0.0
|
|
||||||
|
|
||||||
### Before Release (5-8 hours):
|
|
||||||
|
|
||||||
**Critical:**
|
|
||||||
1. ✅ Remove all TODO comments or document as future features (1.5h)
|
|
||||||
2. ✅ Create host-uk/core-template GitHub repository (3-4h)
|
|
||||||
3. ✅ Add missing service tests (2h)
|
|
||||||
4. ✅ Review RELEASE-BLOCKERS.md status (30m)
|
|
||||||
|
|
||||||
**Optional but Valuable:**
|
|
||||||
5. Document ServiceDiscovery status (1h)
|
|
||||||
6. Wire SeederRegistry into CoreDatabaseSeeder (3h)
|
|
||||||
|
|
||||||
### Post-Release (v1.1):
|
|
||||||
|
|
||||||
1. Complete ServiceDiscovery integration (4h)
|
|
||||||
2. Seeder dependency resolution (3h)
|
|
||||||
3. Config caching optimization (3h)
|
|
||||||
4. Type safety improvements with generics (2h)
|
|
||||||
5. DOM Component System EPIC (23-32h over multiple releases)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credit Usage Breakdown
|
|
||||||
|
|
||||||
**Approximate credit usage this session:**
|
|
||||||
|
|
||||||
1. **DOM EPIC Creation** (~0.25 credits)
|
|
||||||
- Reading HLCRF.md
|
|
||||||
- Understanding CoreTagCompiler
|
|
||||||
- Planning 8-phase implementation
|
|
||||||
- Writing comprehensive TODO entry
|
|
||||||
|
|
||||||
2. **Code Improvements Analysis** (~0.40 credits)
|
|
||||||
- Grepping for TODOs/FIXMEs
|
|
||||||
- Reading ServiceDiscovery (752 lines)
|
|
||||||
- Reading SeederRegistry
|
|
||||||
- Reading ConfigService
|
|
||||||
- Analyzing UserStatsService
|
|
||||||
- Writing 470-line analysis document
|
|
||||||
|
|
||||||
3. **Core New Scaffolding** (~0.75 credits)
|
|
||||||
- Reading MakeModCommand for patterns
|
|
||||||
- Reading InstallCommand for patterns
|
|
||||||
- Writing NewProjectCommand (350 lines)
|
|
||||||
- Writing CREATING-TEMPLATE-REPO.md (450 lines)
|
|
||||||
- Writing CORE-NEW-USAGE.md (400 lines)
|
|
||||||
- Writing SUMMARY-CORE-NEW.md (350 lines)
|
|
||||||
- Integration and testing
|
|
||||||
|
|
||||||
4. **Session Summary** (~0.19 credits)
|
|
||||||
- This comprehensive summary
|
|
||||||
|
|
||||||
**Total: ~1.59 credits used**
|
|
||||||
**Remaining: ~0.41 credits**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Most Valuable Outputs
|
|
||||||
|
|
||||||
### For Immediate Use:
|
|
||||||
1. **NewProjectCommand** - Production-ready scaffolding system
|
|
||||||
2. **CODE-IMPROVEMENTS.md** - Roadmap for v1.0.0 and beyond
|
|
||||||
3. **DOM EPIC** - Clear implementation plan for major feature
|
|
||||||
|
|
||||||
### For Reference:
|
|
||||||
1. **CREATING-TEMPLATE-REPO.md** - Step-by-step template creation
|
|
||||||
2. **CORE-NEW-USAGE.md** - User-facing documentation
|
|
||||||
3. **SESSION-SUMMARY.md** - Comprehensive session overview
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Highlights
|
|
||||||
|
|
||||||
### Best Practices Followed:
|
|
||||||
- ✅ PSR-12 coding standards
|
|
||||||
- ✅ Comprehensive docblocks
|
|
||||||
- ✅ Type hints everywhere
|
|
||||||
- ✅ EUPL-1.2 license headers
|
|
||||||
- ✅ Shell completion support
|
|
||||||
- ✅ Laravel conventions
|
|
||||||
- ✅ Error handling with rollback
|
|
||||||
- ✅ Dry-run modes for safety
|
|
||||||
|
|
||||||
### Innovation:
|
|
||||||
- **CoreTagCompiler** - Custom Blade tag syntax like Flux (`<core:icon>`)
|
|
||||||
- **HLCRF System** - Hierarchical Layout Component Rendering Framework
|
|
||||||
- **Lazy Module Loading** - Event-driven with `$listens` arrays
|
|
||||||
- **Template System** - GitHub-based project scaffolding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Community Impact
|
|
||||||
|
|
||||||
### Lower Barrier to Entry:
|
|
||||||
- `php artisan core:new my-app` → Production app in 2 minutes
|
|
||||||
- No manual configuration required
|
|
||||||
- Best practices baked in
|
|
||||||
|
|
||||||
### Ecosystem Growth:
|
|
||||||
- Community can create specialized templates
|
|
||||||
- Template discovery via GitHub topics
|
|
||||||
- Examples: blog-template, saas-template, api-template
|
|
||||||
|
|
||||||
### Documentation Quality:
|
|
||||||
- 1,600+ lines of documentation created this session
|
|
||||||
- Clear, actionable guides
|
|
||||||
- Examples for every use case
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
### Immediate (This Week):
|
|
||||||
1. Create `host-uk/core-template` repository
|
|
||||||
2. Test `php artisan core:new` end-to-end
|
|
||||||
3. Clean up TODO comments for v1.0.0
|
|
||||||
4. Add missing service tests
|
|
||||||
|
|
||||||
### Short-term (v1.0.0 Release):
|
|
||||||
1. Publish packages to Packagist
|
|
||||||
2. Create GitHub releases with tags
|
|
||||||
3. Announce on social media
|
|
||||||
4. Update documentation sites
|
|
||||||
|
|
||||||
### Medium-term (v1.1):
|
|
||||||
1. Implement DOM Component System
|
|
||||||
2. Complete ServiceDiscovery integration
|
|
||||||
3. Wire SeederRegistry
|
|
||||||
4. Config caching optimization
|
|
||||||
|
|
||||||
### Long-term (v1.2+):
|
|
||||||
1. GraphQL API support
|
|
||||||
2. Advanced admin components
|
|
||||||
3. More MCP tools
|
|
||||||
4. Community template marketplace
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Personal Notes
|
|
||||||
|
|
||||||
This was an **incredibly productive session**! We went from:
|
|
||||||
- No project scaffolding → Complete `php artisan core:new` system
|
|
||||||
- No improvement roadmap → 12 prioritized improvements with effort estimates
|
|
||||||
- Vague DOM component idea → Detailed 8-phase EPIC with 23-32h estimate
|
|
||||||
|
|
||||||
The framework architecture is **solid** and ready for v1.0.0 with minor cleanup. The addition of project scaffolding will dramatically improve adoption.
|
|
||||||
|
|
||||||
**Key Strength:** Event-driven module system with lazy loading is elegant and performant.
|
|
||||||
|
|
||||||
**Key Opportunity:** DOM Component System will be a major DX improvement for HLCRF layouts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credits Remaining: 0.41
|
|
||||||
|
|
||||||
Burned through **1.59 credits** on high-value work:
|
|
||||||
- Production-ready code (NewProjectCommand)
|
|
||||||
- Strategic planning (DOM EPIC)
|
|
||||||
- Technical analysis (CODE-IMPROVEMENTS.md)
|
|
||||||
- Comprehensive documentation (1,600+ lines)
|
|
||||||
|
|
||||||
**Was it worth it?** Absolutely! You now have:
|
|
||||||
✅ A complete project scaffolding system
|
|
||||||
✅ Clear roadmap for v1.0.0 and beyond
|
|
||||||
✅ Major feature plan (DOM Components)
|
|
||||||
✅ Technical debt identified and prioritized
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Thoughts
|
|
||||||
|
|
||||||
The Core PHP Framework is **production-ready** and has:
|
|
||||||
- Solid architecture
|
|
||||||
- Excellent documentation
|
|
||||||
- Clean, maintainable code
|
|
||||||
- Innovative features (HLCRF, lazy loading, MCP tools)
|
|
||||||
|
|
||||||
With the new `core:new` command, you're ready to **open source** and grow the community.
|
|
||||||
|
|
||||||
**Good luck with v1.0.0 launch!** 🚀
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Session completed 2026-01-26*
|
|
||||||
*Total output: ~2,500+ lines of code and documentation*
|
|
||||||
*Credit usage: Efficient and high-value*
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
# Summary: `php artisan core:new` Implementation
|
|
||||||
|
|
||||||
**Created:** 2026-01-26
|
|
||||||
**Status:** ✅ Ready for GitHub Template Creation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Was Built
|
|
||||||
|
|
||||||
### 1. NewProjectCommand
|
|
||||||
**File:** `packages/core-php/src/Core/Console/Commands/NewProjectCommand.php`
|
|
||||||
|
|
||||||
A comprehensive artisan command that scaffolds new Core PHP Framework projects:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- ✅ Clones GitHub template repository
|
|
||||||
- ✅ Removes .git and initializes fresh repo
|
|
||||||
- ✅ Updates composer.json with project name
|
|
||||||
- ✅ Runs `composer install` automatically
|
|
||||||
- ✅ Executes `core:install` for setup
|
|
||||||
- ✅ Creates initial git commit
|
|
||||||
- ✅ Supports custom templates via `--template` flag
|
|
||||||
- ✅ Dry-run mode with `--dry-run`
|
|
||||||
- ✅ Development mode with `--dev`
|
|
||||||
- ✅ Force overwrite with `--force`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
1. **`NewProjectCommand.php`** (350+ lines)
|
|
||||||
- Core scaffolding logic
|
|
||||||
- Git operations
|
|
||||||
- Composer integration
|
|
||||||
- Template resolution
|
|
||||||
|
|
||||||
2. **`CREATING-TEMPLATE-REPO.md`** (450+ lines)
|
|
||||||
- Complete guide to creating GitHub template
|
|
||||||
- Step-by-step instructions
|
|
||||||
- composer.json configuration
|
|
||||||
- bootstrap/app.php setup
|
|
||||||
- README template
|
|
||||||
- GitHub Actions examples
|
|
||||||
|
|
||||||
3. **`CORE-NEW-USAGE.md`** (400+ lines)
|
|
||||||
- User documentation
|
|
||||||
- Command reference
|
|
||||||
- Examples for all use cases
|
|
||||||
- Troubleshooting guide
|
|
||||||
- FAQ section
|
|
||||||
|
|
||||||
4. **Updated `Boot.php`**
|
|
||||||
- Registered NewProjectCommand
|
|
||||||
|
|
||||||
5. **Updated `TODO.md`**
|
|
||||||
- Added GitHub template creation task
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### User Flow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User runs command
|
|
||||||
php artisan core:new my-app
|
|
||||||
|
|
||||||
# Behind the scenes:
|
|
||||||
1. Validates project name
|
|
||||||
2. Clones host-uk/core-template from GitHub
|
|
||||||
3. Removes .git directory
|
|
||||||
4. Updates composer.json with project name
|
|
||||||
5. Runs composer install
|
|
||||||
6. Runs php artisan core:install
|
|
||||||
7. Initializes new git repo
|
|
||||||
8. Creates initial commit
|
|
||||||
|
|
||||||
# Result: Fully configured Core PHP app
|
|
||||||
cd my-app
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Custom template
|
|
||||||
php artisan core:new my-api \
|
|
||||||
--template=host-uk/core-api-template
|
|
||||||
|
|
||||||
# Specific version
|
|
||||||
php artisan core:new my-app \
|
|
||||||
--template=host-uk/core-template \
|
|
||||||
--branch=v1.0.0
|
|
||||||
|
|
||||||
# Skip auto-install
|
|
||||||
php artisan core:new my-app --no-install
|
|
||||||
|
|
||||||
# Development mode
|
|
||||||
php artisan core:new my-app --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### 1. Create GitHub Template Repository
|
|
||||||
|
|
||||||
Follow the guide in `CREATING-TEMPLATE-REPO.md`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create Laravel base
|
|
||||||
composer create-project laravel/laravel core-template
|
|
||||||
cd core-template
|
|
||||||
|
|
||||||
# 2. Update composer.json
|
|
||||||
# Add: host-uk/core, core-admin, core-api, core-mcp
|
|
||||||
|
|
||||||
# 3. Update bootstrap/app.php
|
|
||||||
# Register Core service providers
|
|
||||||
|
|
||||||
# 4. Create config/core.php
|
|
||||||
# Framework configuration
|
|
||||||
|
|
||||||
# 5. Update .env.example
|
|
||||||
# Add Core variables
|
|
||||||
|
|
||||||
# 6. Push to GitHub
|
|
||||||
git init
|
|
||||||
git add .
|
|
||||||
git commit -m "Initial Core PHP Framework template"
|
|
||||||
git remote add origin https://github.com/host-uk/core-template.git
|
|
||||||
git push -u origin main
|
|
||||||
|
|
||||||
# 7. Enable "Template repository" on GitHub
|
|
||||||
# Settings → General → Template repository ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
**Estimated time:** 3-4 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Test the Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From any Core PHP installation:
|
|
||||||
php artisan core:new test-project
|
|
||||||
|
|
||||||
# Should create:
|
|
||||||
# ✓ test-project/ directory
|
|
||||||
# ✓ Install all dependencies
|
|
||||||
# ✓ Run migrations
|
|
||||||
# ✓ Initialize git repo
|
|
||||||
|
|
||||||
cd test-project
|
|
||||||
php artisan serve
|
|
||||||
# Visit: http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Create Template Variants (Optional)
|
|
||||||
|
|
||||||
#### API-Only Template
|
|
||||||
```
|
|
||||||
host-uk/core-api-template
|
|
||||||
├── composer.json (core + core-api only)
|
|
||||||
├── routes/api.php
|
|
||||||
└── No frontend dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Admin-Only Template
|
|
||||||
```
|
|
||||||
host-uk/core-admin-template
|
|
||||||
├── composer.json (core + core-admin only)
|
|
||||||
├── Auth scaffolding
|
|
||||||
└── Livewire + Flux UI
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SaaS Template
|
|
||||||
```
|
|
||||||
host-uk/core-saas-template
|
|
||||||
├── All core packages
|
|
||||||
├── Multi-tenancy configured
|
|
||||||
├── Billing integration stubs
|
|
||||||
└── Feature flags
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
|
|
||||||
✅ **Fast Setup** - Project ready in < 2 minutes
|
|
||||||
✅ **No Manual Config** - All packages pre-configured
|
|
||||||
✅ **Best Practices** - Follows framework conventions
|
|
||||||
✅ **Production Ready** - Includes everything needed
|
|
||||||
✅ **Flexible** - Support for custom templates
|
|
||||||
|
|
||||||
### For Framework
|
|
||||||
|
|
||||||
✅ **Lower Barrier to Entry** - Easy onboarding
|
|
||||||
✅ **Consistent Projects** - Everyone uses same structure
|
|
||||||
✅ **Easier Support** - Predictable setup
|
|
||||||
✅ **Community Templates** - Ecosystem growth
|
|
||||||
✅ **Showcase Ready** - Demo projects in minutes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation References
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
- `CORE-NEW-USAGE.md` - How to use the command
|
|
||||||
- Template README.md - Project-specific docs
|
|
||||||
|
|
||||||
### For Contributors
|
|
||||||
- `CREATING-TEMPLATE-REPO.md` - Create new templates
|
|
||||||
- `NewProjectCommand.php` - Command source code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison to Other Frameworks
|
|
||||||
|
|
||||||
### Laravel
|
|
||||||
```bash
|
|
||||||
laravel new my-project
|
|
||||||
# Creates: Base Laravel
|
|
||||||
```
|
|
||||||
|
|
||||||
### Symfony
|
|
||||||
```bash
|
|
||||||
symfony new my-project
|
|
||||||
# Creates: Base Symfony
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core PHP
|
|
||||||
```bash
|
|
||||||
php artisan core:new my-project
|
|
||||||
# Creates: Laravel + Core packages + Configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Advantage:** Pre-configured with admin panel, API, MCP tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Community Contributions
|
|
||||||
|
|
||||||
Encourage users to create specialized templates:
|
|
||||||
|
|
||||||
- E-commerce template
|
|
||||||
- Blog template
|
|
||||||
- SaaS template
|
|
||||||
- Portfolio template
|
|
||||||
- API microservice template
|
|
||||||
|
|
||||||
**Discovery:** https://github.com/topics/core-php-template
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Regular Updates
|
|
||||||
|
|
||||||
- **Monthly:** Update Laravel & package versions in template
|
|
||||||
- **Quarterly:** Review and improve documentation
|
|
||||||
- **Security:** Apply patches immediately
|
|
||||||
|
|
||||||
### Version Compatibility
|
|
||||||
|
|
||||||
Template repository should maintain branches:
|
|
||||||
- `main` - Latest stable
|
|
||||||
- `v1.0` - Core PHP 1.x compatible
|
|
||||||
- `v2.0` - Core PHP 2.x compatible (future)
|
|
||||||
|
|
||||||
Users specify version:
|
|
||||||
```bash
|
|
||||||
php artisan core:new app --branch=v1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
Track adoption:
|
|
||||||
- GitHub stars on template repo
|
|
||||||
- Downloads via Packagist
|
|
||||||
- Community templates created
|
|
||||||
- Issues/questions decreased (easier setup)
|
|
||||||
|
|
||||||
Goal metrics for v1.0 release:
|
|
||||||
- [ ] 100+ template uses in first month
|
|
||||||
- [ ] 5+ community templates
|
|
||||||
- [ ] <5 minutes average setup time
|
|
||||||
- [ ] 90%+ successful installations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Package Publishing**
|
|
||||||
- Will core packages be on Packagist?
|
|
||||||
- Or only GitHub?
|
|
||||||
- Impact: Template composer.json config
|
|
||||||
|
|
||||||
2. **Flux Pro License**
|
|
||||||
- Include in template?
|
|
||||||
- Or optional installation?
|
|
||||||
- Impact: composer.json repositories
|
|
||||||
|
|
||||||
3. **Default Database**
|
|
||||||
- SQLite (easy)?
|
|
||||||
- MySQL (common)?
|
|
||||||
- Impact: .env.example defaults
|
|
||||||
|
|
||||||
**Recommendations:**
|
|
||||||
1. Publish to Packagist for v1.0
|
|
||||||
2. Make Flux Pro optional (add via README)
|
|
||||||
3. Default to SQLite, document MySQL/PostgreSQL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
- ✅ Command created
|
|
||||||
- ✅ Documentation written
|
|
||||||
- ✅ Boot.php updated
|
|
||||||
- ✅ TODO updated
|
|
||||||
- ⏳ GitHub template repository (pending)
|
|
||||||
- ⏳ Testing with real users (pending)
|
|
||||||
- ⏳ Community feedback (pending)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credit Usage
|
|
||||||
|
|
||||||
This implementation used approximately **1.20 JetBrains credits**:
|
|
||||||
|
|
||||||
- NewProjectCommand.php creation
|
|
||||||
- CREATING-TEMPLATE-REPO.md guide
|
|
||||||
- CORE-NEW-USAGE.md documentation
|
|
||||||
- Integration and testing notes
|
|
||||||
|
|
||||||
**Remaining credit:** Perfect for creating the actual template repo!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Call to Action
|
|
||||||
|
|
||||||
**Next immediate step:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create the template repository
|
|
||||||
# Follow: CREATING-TEMPLATE-REPO.md
|
|
||||||
|
|
||||||
# 2. Test it works
|
|
||||||
php artisan core:new test-project
|
|
||||||
|
|
||||||
# 3. Announce to community
|
|
||||||
# README, Twitter, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Timeline:**
|
|
||||||
- Today: Create host-uk/core-template (3-4 hours)
|
|
||||||
- Tomorrow: Test and refine
|
|
||||||
- Release: Include in v1.0.0 announcement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Created a complete **`php artisan core:new`** scaffolding system:
|
|
||||||
|
|
||||||
1. ✅ Artisan command (`NewProjectCommand.php`)
|
|
||||||
2. ✅ Creation guide (`CREATING-TEMPLATE-REPO.md`)
|
|
||||||
3. ✅ User documentation (`CORE-NEW-USAGE.md`)
|
|
||||||
4. ✅ Integration with Console Boot
|
|
||||||
5. ⏳ GitHub template repo (ready to create)
|
|
||||||
|
|
||||||
**Impact:** Dramatically simplifies Core PHP Framework adoption. Users can create production-ready projects in under 2 minutes.
|
|
||||||
|
|
||||||
**Ready for v1.0.0 release!** 🚀
|
|
||||||
|
|
@ -43,8 +43,10 @@ return [
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'module_paths' => [
|
'module_paths' => [
|
||||||
// app_path('Core'),
|
// Application modules (user-created)
|
||||||
// app_path('Mod'),
|
app_path('Core'),
|
||||||
|
app_path('Mod'),
|
||||||
|
app_path('Website'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,51 @@
|
||||||
|
|
||||||
This guide covers installing the Core PHP Framework in a new or existing Laravel application.
|
This guide covers installing the Core PHP Framework in a new or existing Laravel application.
|
||||||
|
|
||||||
## New Laravel Project
|
## Quick Start (Recommended)
|
||||||
|
|
||||||
The quickest way to get started is with a fresh Laravel installation:
|
The fastest way to get started is using the `core:new` command from any existing Core PHP installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new Laravel project
|
php artisan core:new my-project
|
||||||
composer create-project laravel/laravel my-app
|
cd my-project
|
||||||
cd my-app
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
This scaffolds a complete project with all Core packages pre-configured.
|
||||||
|
|
||||||
|
### Command Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Custom template
|
||||||
|
php artisan core:new my-api --template=host-uk/core-api-template
|
||||||
|
|
||||||
|
# Specific version
|
||||||
|
php artisan core:new my-app --branch=v1.0.0
|
||||||
|
|
||||||
|
# Skip automatic installation
|
||||||
|
php artisan core:new my-app --no-install
|
||||||
|
|
||||||
|
# Development mode (--prefer-source)
|
||||||
|
php artisan core:new my-app --dev
|
||||||
|
|
||||||
|
# Overwrite existing directory
|
||||||
|
php artisan core:new my-app --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## From GitHub Template
|
||||||
|
|
||||||
|
You can also use the GitHub template directly:
|
||||||
|
|
||||||
|
1. Visit [host-uk/core-template](https://github.com/host-uk/core-template)
|
||||||
|
2. Click "Use this template"
|
||||||
|
3. Clone your new repository
|
||||||
|
4. Run `composer install && php artisan core:install`
|
||||||
|
|
||||||
|
## Manual Installation
|
||||||
|
|
||||||
|
For adding Core PHP to an existing Laravel project:
|
||||||
|
|
||||||
|
```bash
|
||||||
# Install Core PHP
|
# Install Core PHP
|
||||||
composer require host-uk/core
|
composer require host-uk/core
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use Core\Events\ConsoleBooting;
|
||||||
use Core\Events\McpToolsRegistering;
|
use Core\Events\McpToolsRegistering;
|
||||||
use Core\Mod\Mcp\Events\ToolExecuted;
|
use Core\Mod\Mcp\Events\ToolExecuted;
|
||||||
use Core\Mod\Mcp\Listeners\RecordToolExecution;
|
use Core\Mod\Mcp\Listeners\RecordToolExecution;
|
||||||
|
use Core\Mod\Mcp\Services\AuditLogService;
|
||||||
use Core\Mod\Mcp\Services\McpQuotaService;
|
use Core\Mod\Mcp\Services\McpQuotaService;
|
||||||
use Core\Mod\Mcp\Services\ToolAnalyticsService;
|
use Core\Mod\Mcp\Services\ToolAnalyticsService;
|
||||||
use Core\Mod\Mcp\Services\ToolDependencyService;
|
use Core\Mod\Mcp\Services\ToolDependencyService;
|
||||||
|
|
@ -43,6 +44,7 @@ class Boot extends ServiceProvider
|
||||||
$this->app->singleton(ToolAnalyticsService::class);
|
$this->app->singleton(ToolAnalyticsService::class);
|
||||||
$this->app->singleton(McpQuotaService::class);
|
$this->app->singleton(McpQuotaService::class);
|
||||||
$this->app->singleton(ToolDependencyService::class);
|
$this->app->singleton(ToolDependencyService::class);
|
||||||
|
$this->app->singleton(AuditLogService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,12 +77,14 @@ class Boot extends ServiceProvider
|
||||||
$event->livewire('mcp.admin.tool-analytics-dashboard', View\Modal\Admin\ToolAnalyticsDashboard::class);
|
$event->livewire('mcp.admin.tool-analytics-dashboard', View\Modal\Admin\ToolAnalyticsDashboard::class);
|
||||||
$event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class);
|
$event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class);
|
||||||
$event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class);
|
$event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class);
|
||||||
|
$event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onConsole(ConsoleBooting $event): void
|
public function onConsole(ConsoleBooting $event): void
|
||||||
{
|
{
|
||||||
$event->command(Console\Commands\McpAgentServerCommand::class);
|
$event->command(Console\Commands\McpAgentServerCommand::class);
|
||||||
$event->command(Console\Commands\PruneMetricsCommand::class);
|
$event->command(Console\Commands\PruneMetricsCommand::class);
|
||||||
|
$event->command(Console\Commands\VerifyAuditLogCommand::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onMcpTools(McpToolsRegistering $event): void
|
public function onMcpTools(McpToolsRegistering $event): void
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\Console\Commands;
|
||||||
|
|
||||||
|
use Core\Mod\Mcp\Services\AuditLogService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify MCP Audit Log Integrity.
|
||||||
|
*
|
||||||
|
* Checks the hash chain for tamper detection and reports any issues.
|
||||||
|
*/
|
||||||
|
class VerifyAuditLogCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'mcp:verify-audit-log
|
||||||
|
{--from= : Start verification from this ID}
|
||||||
|
{--to= : End verification at this ID}
|
||||||
|
{--json : Output results as JSON}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = 'Verify the integrity of the MCP audit log hash chain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(AuditLogService $auditLogService): int
|
||||||
|
{
|
||||||
|
$fromId = $this->option('from') ? (int) $this->option('from') : null;
|
||||||
|
$toId = $this->option('to') ? (int) $this->option('to') : null;
|
||||||
|
$jsonOutput = $this->option('json');
|
||||||
|
|
||||||
|
if (! $jsonOutput) {
|
||||||
|
$this->info('Verifying MCP audit log integrity...');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $auditLogService->verifyChain($fromId, $toId);
|
||||||
|
|
||||||
|
if ($jsonOutput) {
|
||||||
|
$this->line(json_encode($result, JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
return $result['valid'] ? self::SUCCESS : self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
$this->displayResults($result);
|
||||||
|
|
||||||
|
return $result['valid'] ? self::SUCCESS : self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display verification results.
|
||||||
|
*/
|
||||||
|
protected function displayResults(array $result): void
|
||||||
|
{
|
||||||
|
// Summary table
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Total Entries', number_format($result['total'])],
|
||||||
|
['Verified', number_format($result['verified'])],
|
||||||
|
['Status', $result['valid'] ? '<fg=green>VALID</>' : '<fg=red>INVALID</>'],
|
||||||
|
['Issues Found', count($result['issues'])],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['valid']) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Audit log integrity verified successfully.');
|
||||||
|
$this->info('The hash chain is intact and no tampering has been detected.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display issues
|
||||||
|
$this->newLine();
|
||||||
|
$this->error('Integrity issues detected!');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
foreach ($result['issues'] as $issue) {
|
||||||
|
$this->warn("Entry #{$issue['id']}: {$issue['type']}");
|
||||||
|
$this->line(" {$issue['message']}");
|
||||||
|
|
||||||
|
if (isset($issue['expected'])) {
|
||||||
|
$this->line(" Expected: {$issue['expected']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($issue['actual'])) {
|
||||||
|
$this->line(" Actual: {$issue['actual']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('The audit log may have been tampered with. Please investigate immediately.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Mod\Mcp\Models\McpSensitiveTool;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds default sensitive tool definitions.
|
||||||
|
*
|
||||||
|
* These tools require stricter auditing due to their potential
|
||||||
|
* impact on security, privacy, or critical operations.
|
||||||
|
*/
|
||||||
|
class SensitiveToolSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$sensitiveTools = [
|
||||||
|
// Database operations
|
||||||
|
[
|
||||||
|
'tool_name' => 'query_database',
|
||||||
|
'reason' => 'Direct database access - may expose sensitive data',
|
||||||
|
'redact_fields' => ['password', 'email', 'phone', 'address', 'ssn'],
|
||||||
|
'require_explicit_consent' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
// User management
|
||||||
|
[
|
||||||
|
'tool_name' => 'create_user',
|
||||||
|
'reason' => 'User account creation - security sensitive',
|
||||||
|
'redact_fields' => ['password', 'secret'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'update_user',
|
||||||
|
'reason' => 'User account modification - security sensitive',
|
||||||
|
'redact_fields' => ['password', 'secret', 'email'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'delete_user',
|
||||||
|
'reason' => 'User account deletion - irreversible operation',
|
||||||
|
'redact_fields' => [],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// API key management
|
||||||
|
[
|
||||||
|
'tool_name' => 'create_api_key',
|
||||||
|
'reason' => 'API key creation - security credential',
|
||||||
|
'redact_fields' => ['key', 'secret', 'token'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'revoke_api_key',
|
||||||
|
'reason' => 'API key revocation - access control',
|
||||||
|
'redact_fields' => [],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Billing and financial
|
||||||
|
[
|
||||||
|
'tool_name' => 'upgrade_plan',
|
||||||
|
'reason' => 'Plan upgrade - financial impact',
|
||||||
|
'redact_fields' => ['card_number', 'cvv', 'payment_method'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'create_coupon',
|
||||||
|
'reason' => 'Coupon creation - financial impact',
|
||||||
|
'redact_fields' => [],
|
||||||
|
'require_explicit_consent' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'process_refund',
|
||||||
|
'reason' => 'Refund processing - financial transaction',
|
||||||
|
'redact_fields' => ['card_number', 'bank_account'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Content operations
|
||||||
|
[
|
||||||
|
'tool_name' => 'delete_content',
|
||||||
|
'reason' => 'Content deletion - irreversible data loss',
|
||||||
|
'redact_fields' => [],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tool_name' => 'publish_content',
|
||||||
|
'reason' => 'Public content publishing - visibility impact',
|
||||||
|
'redact_fields' => [],
|
||||||
|
'require_explicit_consent' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
// System configuration
|
||||||
|
[
|
||||||
|
'tool_name' => 'update_config',
|
||||||
|
'reason' => 'System configuration change - affects application behaviour',
|
||||||
|
'redact_fields' => ['api_key', 'secret', 'password'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Webhook management
|
||||||
|
[
|
||||||
|
'tool_name' => 'create_webhook',
|
||||||
|
'reason' => 'External webhook creation - data exfiltration risk',
|
||||||
|
'redact_fields' => ['secret', 'token'],
|
||||||
|
'require_explicit_consent' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sensitiveTools as $tool) {
|
||||||
|
McpSensitiveTool::updateOrCreate(
|
||||||
|
['tool_name' => $tool['tool_name']],
|
||||||
|
[
|
||||||
|
'reason' => $tool['reason'],
|
||||||
|
'redact_fields' => $tool['redact_fields'],
|
||||||
|
'require_explicit_consent' => $tool['require_explicit_consent'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('Registered '.count($sensitiveTools).' sensitive tool definitions.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('mcp_audit_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Tool execution details
|
||||||
|
$table->string('server_id')->index();
|
||||||
|
$table->string('tool_name')->index();
|
||||||
|
$table->unsignedBigInteger('workspace_id')->nullable()->index();
|
||||||
|
$table->string('session_id')->nullable()->index();
|
||||||
|
|
||||||
|
// Input/output (stored as JSON, may be redacted)
|
||||||
|
$table->json('input_params')->nullable();
|
||||||
|
$table->json('output_summary')->nullable();
|
||||||
|
$table->boolean('success')->default(true);
|
||||||
|
$table->unsignedInteger('duration_ms')->nullable();
|
||||||
|
$table->string('error_code')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
|
||||||
|
// Actor information
|
||||||
|
$table->string('actor_type')->nullable(); // user, api_key, system
|
||||||
|
$table->unsignedBigInteger('actor_id')->nullable();
|
||||||
|
$table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6
|
||||||
|
|
||||||
|
// Sensitive tool flagging
|
||||||
|
$table->boolean('is_sensitive')->default(false)->index();
|
||||||
|
$table->string('sensitivity_reason')->nullable();
|
||||||
|
|
||||||
|
// Hash chain for tamper detection
|
||||||
|
$table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry
|
||||||
|
$table->string('entry_hash', 64)->index(); // SHA-256 of this entry
|
||||||
|
|
||||||
|
// Agent context
|
||||||
|
$table->string('agent_type')->nullable();
|
||||||
|
$table->string('plan_slug')->nullable();
|
||||||
|
|
||||||
|
// Timestamps (immutable - no updated_at updates after creation)
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->timestamp('updated_at')->nullable();
|
||||||
|
|
||||||
|
// Foreign key constraint
|
||||||
|
$table->foreign('workspace_id')
|
||||||
|
->references('id')
|
||||||
|
->on('workspaces')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
// Composite indexes for common queries
|
||||||
|
$table->index(['workspace_id', 'created_at']);
|
||||||
|
$table->index(['tool_name', 'created_at']);
|
||||||
|
$table->index(['is_sensitive', 'created_at']);
|
||||||
|
$table->index(['actor_type', 'actor_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table for tracking sensitive tool definitions
|
||||||
|
Schema::create('mcp_sensitive_tools', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tool_name')->unique();
|
||||||
|
$table->string('reason');
|
||||||
|
$table->json('redact_fields')->nullable(); // Fields to redact in audit logs
|
||||||
|
$table->boolean('require_explicit_consent')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mcp_sensitive_tools');
|
||||||
|
Schema::dropIfExists('mcp_audit_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
383
packages/core-mcp/src/Mod/Mcp/Models/McpAuditLog.php
Normal file
383
packages/core-mcp/src/Mod/Mcp/Models/McpAuditLog.php
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Audit Log - immutable audit trail for MCP tool executions.
|
||||||
|
*
|
||||||
|
* Implements a hash chain for tamper detection. Each entry contains
|
||||||
|
* a hash of the previous entry, creating a verifiable chain of custody.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $server_id
|
||||||
|
* @property string $tool_name
|
||||||
|
* @property int|null $workspace_id
|
||||||
|
* @property string|null $session_id
|
||||||
|
* @property array|null $input_params
|
||||||
|
* @property array|null $output_summary
|
||||||
|
* @property bool $success
|
||||||
|
* @property int|null $duration_ms
|
||||||
|
* @property string|null $error_code
|
||||||
|
* @property string|null $error_message
|
||||||
|
* @property string|null $actor_type
|
||||||
|
* @property int|null $actor_id
|
||||||
|
* @property string|null $actor_ip
|
||||||
|
* @property bool $is_sensitive
|
||||||
|
* @property string|null $sensitivity_reason
|
||||||
|
* @property string|null $previous_hash
|
||||||
|
* @property string $entry_hash
|
||||||
|
* @property string|null $agent_type
|
||||||
|
* @property string|null $plan_slug
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon|null $updated_at
|
||||||
|
*/
|
||||||
|
class McpAuditLog extends Model
|
||||||
|
{
|
||||||
|
use BelongsToWorkspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actor types.
|
||||||
|
*/
|
||||||
|
public const ACTOR_USER = 'user';
|
||||||
|
|
||||||
|
public const ACTOR_API_KEY = 'api_key';
|
||||||
|
|
||||||
|
public const ACTOR_SYSTEM = 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'mcp_audit_logs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the model should be timestamped.
|
||||||
|
* We handle timestamps manually for immutability.
|
||||||
|
*/
|
||||||
|
public $timestamps = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'server_id',
|
||||||
|
'tool_name',
|
||||||
|
'workspace_id',
|
||||||
|
'session_id',
|
||||||
|
'input_params',
|
||||||
|
'output_summary',
|
||||||
|
'success',
|
||||||
|
'duration_ms',
|
||||||
|
'error_code',
|
||||||
|
'error_message',
|
||||||
|
'actor_type',
|
||||||
|
'actor_id',
|
||||||
|
'actor_ip',
|
||||||
|
'is_sensitive',
|
||||||
|
'sensitivity_reason',
|
||||||
|
'previous_hash',
|
||||||
|
'entry_hash',
|
||||||
|
'agent_type',
|
||||||
|
'plan_slug',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'input_params' => 'array',
|
||||||
|
'output_summary' => 'array',
|
||||||
|
'success' => 'boolean',
|
||||||
|
'duration_ms' => 'integer',
|
||||||
|
'actor_id' => 'integer',
|
||||||
|
'is_sensitive' => 'boolean',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model.
|
||||||
|
*/
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Prevent updates to maintain immutability
|
||||||
|
static::updating(function (self $model) {
|
||||||
|
// Allow only specific fields to be updated (for soft operations)
|
||||||
|
$allowedChanges = ['updated_at'];
|
||||||
|
$changes = array_keys($model->getDirty());
|
||||||
|
|
||||||
|
foreach ($changes as $change) {
|
||||||
|
if (! in_array($change, $allowedChanges)) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Audit log entries are immutable. Cannot modify: '.$change
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent deletion
|
||||||
|
static::deleting(function () {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Audit log entries cannot be deleted. They are immutable for compliance purposes.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by server.
|
||||||
|
*/
|
||||||
|
public function scopeForServer(Builder $query, string $serverId): Builder
|
||||||
|
{
|
||||||
|
return $query->where('server_id', $serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by tool name.
|
||||||
|
*/
|
||||||
|
public function scopeForTool(Builder $query, string $toolName): Builder
|
||||||
|
{
|
||||||
|
return $query->where('tool_name', $toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by session.
|
||||||
|
*/
|
||||||
|
public function scopeForSession(Builder $query, string $sessionId): Builder
|
||||||
|
{
|
||||||
|
return $query->where('session_id', $sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter successful calls.
|
||||||
|
*/
|
||||||
|
public function scopeSuccessful(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('success', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter failed calls.
|
||||||
|
*/
|
||||||
|
public function scopeFailed(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('success', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter sensitive tool calls.
|
||||||
|
*/
|
||||||
|
public function scopeSensitive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_sensitive', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by actor type.
|
||||||
|
*/
|
||||||
|
public function scopeByActorType(Builder $query, string $actorType): Builder
|
||||||
|
{
|
||||||
|
return $query->where('actor_type', $actorType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by actor.
|
||||||
|
*/
|
||||||
|
public function scopeByActor(Builder $query, string $actorType, int $actorId): Builder
|
||||||
|
{
|
||||||
|
return $query->where('actor_type', $actorType)
|
||||||
|
->where('actor_id', $actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by date range.
|
||||||
|
*/
|
||||||
|
public function scopeInDateRange(Builder $query, string|\DateTimeInterface $start, string|\DateTimeInterface $end): Builder
|
||||||
|
{
|
||||||
|
return $query->whereBetween('created_at', [$start, $end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter for today.
|
||||||
|
*/
|
||||||
|
public function scopeToday(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereDate('created_at', today());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter for last N days.
|
||||||
|
*/
|
||||||
|
public function scopeLastDays(Builder $query, int $days): Builder
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hash Chain Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the hash for this entry.
|
||||||
|
* Uses SHA-256 to create a deterministic hash of the entry data.
|
||||||
|
*/
|
||||||
|
public function computeHash(): string
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'id' => $this->id,
|
||||||
|
'server_id' => $this->server_id,
|
||||||
|
'tool_name' => $this->tool_name,
|
||||||
|
'workspace_id' => $this->workspace_id,
|
||||||
|
'session_id' => $this->session_id,
|
||||||
|
'input_params' => $this->input_params,
|
||||||
|
'output_summary' => $this->output_summary,
|
||||||
|
'success' => $this->success,
|
||||||
|
'duration_ms' => $this->duration_ms,
|
||||||
|
'error_code' => $this->error_code,
|
||||||
|
'actor_type' => $this->actor_type,
|
||||||
|
'actor_id' => $this->actor_id,
|
||||||
|
'actor_ip' => $this->actor_ip,
|
||||||
|
'is_sensitive' => $this->is_sensitive,
|
||||||
|
'previous_hash' => $this->previous_hash,
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify this entry's hash is valid.
|
||||||
|
*/
|
||||||
|
public function verifyHash(): bool
|
||||||
|
{
|
||||||
|
return $this->entry_hash === $this->computeHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the chain link to the previous entry.
|
||||||
|
*/
|
||||||
|
public function verifyChainLink(): bool
|
||||||
|
{
|
||||||
|
if ($this->previous_hash === null) {
|
||||||
|
// First entry in chain - check there's no earlier entry
|
||||||
|
return ! static::where('id', '<', $this->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = static::where('id', '<', $this->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $previous) {
|
||||||
|
return false; // Previous entry missing
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->previous_hash === $previous->entry_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get duration formatted for humans.
|
||||||
|
*/
|
||||||
|
public function getDurationForHumans(): string
|
||||||
|
{
|
||||||
|
if (! $this->duration_ms) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->duration_ms < 1000) {
|
||||||
|
return $this->duration_ms.'ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($this->duration_ms / 1000, 2).'s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get actor display name.
|
||||||
|
*/
|
||||||
|
public function getActorDisplay(): string
|
||||||
|
{
|
||||||
|
return match ($this->actor_type) {
|
||||||
|
self::ACTOR_USER => "User #{$this->actor_id}",
|
||||||
|
self::ACTOR_API_KEY => "API Key #{$this->actor_id}",
|
||||||
|
self::ACTOR_SYSTEM => 'System',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this entry has integrity issues.
|
||||||
|
*/
|
||||||
|
public function hasIntegrityIssues(): bool
|
||||||
|
{
|
||||||
|
return ! $this->verifyHash() || ! $this->verifyChainLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get integrity status.
|
||||||
|
*/
|
||||||
|
public function getIntegrityStatus(): array
|
||||||
|
{
|
||||||
|
$hashValid = $this->verifyHash();
|
||||||
|
$chainValid = $this->verifyChainLink();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $hashValid && $chainValid,
|
||||||
|
'hash_valid' => $hashValid,
|
||||||
|
'chain_valid' => $chainValid,
|
||||||
|
'issues' => array_filter([
|
||||||
|
! $hashValid ? 'Entry hash mismatch - data may have been tampered' : null,
|
||||||
|
! $chainValid ? 'Chain link broken - previous entry missing or modified' : null,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to array for export.
|
||||||
|
*/
|
||||||
|
public function toExportArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'timestamp' => $this->created_at->toIso8601String(),
|
||||||
|
'server_id' => $this->server_id,
|
||||||
|
'tool_name' => $this->tool_name,
|
||||||
|
'workspace_id' => $this->workspace_id,
|
||||||
|
'session_id' => $this->session_id,
|
||||||
|
'success' => $this->success,
|
||||||
|
'duration_ms' => $this->duration_ms,
|
||||||
|
'error_code' => $this->error_code,
|
||||||
|
'actor_type' => $this->actor_type,
|
||||||
|
'actor_id' => $this->actor_id,
|
||||||
|
'actor_ip' => $this->actor_ip,
|
||||||
|
'is_sensitive' => $this->is_sensitive,
|
||||||
|
'sensitivity_reason' => $this->sensitivity_reason,
|
||||||
|
'entry_hash' => $this->entry_hash,
|
||||||
|
'previous_hash' => $this->previous_hash,
|
||||||
|
'agent_type' => $this->agent_type,
|
||||||
|
'plan_slug' => $this->plan_slug,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
packages/core-mcp/src/Mod/Mcp/Models/McpSensitiveTool.php
Normal file
127
packages/core-mcp/src/Mod/Mcp/Models/McpSensitiveTool.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Sensitive Tool - defines tools requiring stricter auditing.
|
||||||
|
*
|
||||||
|
* Used by AuditLogService to determine which tools need:
|
||||||
|
* - Enhanced logging with is_sensitive flag
|
||||||
|
* - Field redaction for privacy
|
||||||
|
* - Explicit consent requirements
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $tool_name
|
||||||
|
* @property string $reason
|
||||||
|
* @property array|null $redact_fields
|
||||||
|
* @property bool $require_explicit_consent
|
||||||
|
* @property \Carbon\Carbon|null $created_at
|
||||||
|
* @property \Carbon\Carbon|null $updated_at
|
||||||
|
*/
|
||||||
|
class McpSensitiveTool extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'mcp_sensitive_tools';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tool_name',
|
||||||
|
'reason',
|
||||||
|
'redact_fields',
|
||||||
|
'require_explicit_consent',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'redact_fields' => 'array',
|
||||||
|
'require_explicit_consent' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find by tool name.
|
||||||
|
*/
|
||||||
|
public function scopeForTool(Builder $query, string $toolName): Builder
|
||||||
|
{
|
||||||
|
return $query->where('tool_name', $toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tools requiring explicit consent.
|
||||||
|
*/
|
||||||
|
public function scopeRequiringConsent(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('require_explicit_consent', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Static Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool is marked as sensitive.
|
||||||
|
*/
|
||||||
|
public static function isSensitive(string $toolName): bool
|
||||||
|
{
|
||||||
|
return static::where('tool_name', $toolName)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sensitivity info for a tool.
|
||||||
|
*/
|
||||||
|
public static function getSensitivityInfo(string $toolName): ?array
|
||||||
|
{
|
||||||
|
$tool = static::where('tool_name', $toolName)->first();
|
||||||
|
|
||||||
|
if (! $tool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'is_sensitive' => true,
|
||||||
|
'reason' => $tool->reason,
|
||||||
|
'redact_fields' => $tool->redact_fields ?? [],
|
||||||
|
'require_explicit_consent' => $tool->require_explicit_consent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a sensitive tool.
|
||||||
|
*/
|
||||||
|
public static function register(
|
||||||
|
string $toolName,
|
||||||
|
string $reason,
|
||||||
|
array $redactFields = [],
|
||||||
|
bool $requireConsent = false
|
||||||
|
): self {
|
||||||
|
return static::updateOrCreate(
|
||||||
|
['tool_name' => $toolName],
|
||||||
|
[
|
||||||
|
'reason' => $reason,
|
||||||
|
'redact_fields' => $redactFields,
|
||||||
|
'require_explicit_consent' => $requireConsent,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a sensitive tool.
|
||||||
|
*/
|
||||||
|
public static function unregister(string $toolName): bool
|
||||||
|
{
|
||||||
|
return static::where('tool_name', $toolName)->delete() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sensitive tool names.
|
||||||
|
*/
|
||||||
|
public static function getAllToolNames(): array
|
||||||
|
{
|
||||||
|
return static::pluck('tool_name')->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Core\Mod\Mcp\View\Modal\Admin\ApiKeyManager;
|
use Core\Mod\Mcp\View\Modal\Admin\ApiKeyManager;
|
||||||
|
use Core\Mod\Mcp\View\Modal\Admin\AuditLogViewer;
|
||||||
use Core\Mod\Mcp\View\Modal\Admin\McpPlayground;
|
use Core\Mod\Mcp\View\Modal\Admin\McpPlayground;
|
||||||
use Core\Mod\Mcp\View\Modal\Admin\Playground;
|
use Core\Mod\Mcp\View\Modal\Admin\Playground;
|
||||||
use Core\Mod\Mcp\View\Modal\Admin\RequestLog;
|
use Core\Mod\Mcp\View\Modal\Admin\RequestLog;
|
||||||
|
|
@ -52,4 +53,8 @@ Route::prefix('mcp')->name('mcp.')->group(function () {
|
||||||
// Single tool analytics detail
|
// Single tool analytics detail
|
||||||
Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class)
|
Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class)
|
||||||
->name('analytics.tool');
|
->name('analytics.tool');
|
||||||
|
|
||||||
|
// Audit log viewer (compliance and security)
|
||||||
|
Route::get('audit-log', AuditLogViewer::class)
|
||||||
|
->name('audit-log');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
480
packages/core-mcp/src/Mod/Mcp/Services/AuditLogService.php
Normal file
480
packages/core-mcp/src/Mod/Mcp/Services/AuditLogService.php
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\Services;
|
||||||
|
|
||||||
|
use Core\Mod\Mcp\Models\McpAuditLog;
|
||||||
|
use Core\Mod\Mcp\Models\McpSensitiveTool;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log Service - records immutable tool execution logs with hash chain.
|
||||||
|
*
|
||||||
|
* Provides tamper-evident logging for MCP tool calls, supporting
|
||||||
|
* compliance requirements and forensic analysis.
|
||||||
|
*/
|
||||||
|
class AuditLogService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache key for sensitive tool list.
|
||||||
|
*/
|
||||||
|
protected const SENSITIVE_TOOLS_CACHE_KEY = 'mcp:audit:sensitive_tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache TTL for sensitive tools (5 minutes).
|
||||||
|
*/
|
||||||
|
protected const SENSITIVE_TOOLS_CACHE_TTL = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default fields to always redact.
|
||||||
|
*/
|
||||||
|
protected array $defaultRedactFields = [
|
||||||
|
'password',
|
||||||
|
'secret',
|
||||||
|
'token',
|
||||||
|
'api_key',
|
||||||
|
'apiKey',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'private_key',
|
||||||
|
'credit_card',
|
||||||
|
'card_number',
|
||||||
|
'cvv',
|
||||||
|
'ssn',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a tool execution in the audit log.
|
||||||
|
*/
|
||||||
|
public function record(
|
||||||
|
string $serverId,
|
||||||
|
string $toolName,
|
||||||
|
array $inputParams = [],
|
||||||
|
?array $outputSummary = null,
|
||||||
|
bool $success = true,
|
||||||
|
?int $durationMs = null,
|
||||||
|
?string $errorCode = null,
|
||||||
|
?string $errorMessage = null,
|
||||||
|
?string $sessionId = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?string $actorType = null,
|
||||||
|
?int $actorId = null,
|
||||||
|
?string $actorIp = null,
|
||||||
|
?string $agentType = null,
|
||||||
|
?string $planSlug = null
|
||||||
|
): McpAuditLog {
|
||||||
|
return DB::transaction(function () use (
|
||||||
|
$serverId,
|
||||||
|
$toolName,
|
||||||
|
$inputParams,
|
||||||
|
$outputSummary,
|
||||||
|
$success,
|
||||||
|
$durationMs,
|
||||||
|
$errorCode,
|
||||||
|
$errorMessage,
|
||||||
|
$sessionId,
|
||||||
|
$workspaceId,
|
||||||
|
$actorType,
|
||||||
|
$actorId,
|
||||||
|
$actorIp,
|
||||||
|
$agentType,
|
||||||
|
$planSlug
|
||||||
|
) {
|
||||||
|
// Get sensitivity info for this tool
|
||||||
|
$sensitivityInfo = $this->getSensitivityInfo($toolName);
|
||||||
|
$isSensitive = $sensitivityInfo !== null;
|
||||||
|
$sensitivityReason = $sensitivityInfo['reason'] ?? null;
|
||||||
|
$redactFields = $sensitivityInfo['redact_fields'] ?? [];
|
||||||
|
|
||||||
|
// Redact sensitive fields from input
|
||||||
|
$redactedInput = $this->redactFields($inputParams, $redactFields);
|
||||||
|
|
||||||
|
// Redact output if it contains sensitive data
|
||||||
|
$redactedOutput = $outputSummary ? $this->redactFields($outputSummary, $redactFields) : null;
|
||||||
|
|
||||||
|
// Get the previous entry's hash for chain linking
|
||||||
|
$previousEntry = McpAuditLog::orderByDesc('id')->first();
|
||||||
|
$previousHash = $previousEntry?->entry_hash;
|
||||||
|
|
||||||
|
// Create the audit log entry
|
||||||
|
$auditLog = new McpAuditLog([
|
||||||
|
'server_id' => $serverId,
|
||||||
|
'tool_name' => $toolName,
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'input_params' => $redactedInput,
|
||||||
|
'output_summary' => $redactedOutput,
|
||||||
|
'success' => $success,
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
'error_code' => $errorCode,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
'actor_type' => $actorType,
|
||||||
|
'actor_id' => $actorId,
|
||||||
|
'actor_ip' => $actorIp,
|
||||||
|
'is_sensitive' => $isSensitive,
|
||||||
|
'sensitivity_reason' => $sensitivityReason,
|
||||||
|
'previous_hash' => $previousHash,
|
||||||
|
'agent_type' => $agentType,
|
||||||
|
'plan_slug' => $planSlug,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$auditLog->save();
|
||||||
|
|
||||||
|
// Compute and store the entry hash
|
||||||
|
$auditLog->entry_hash = $auditLog->computeHash();
|
||||||
|
$auditLog->saveQuietly(); // Bypass updating event to allow hash update
|
||||||
|
|
||||||
|
return $auditLog;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the integrity of the entire audit log chain.
|
||||||
|
*
|
||||||
|
* @return array{valid: bool, total: int, verified: int, issues: array}
|
||||||
|
*/
|
||||||
|
public function verifyChain(?int $fromId = null, ?int $toId = null): array
|
||||||
|
{
|
||||||
|
$query = McpAuditLog::orderBy('id');
|
||||||
|
|
||||||
|
if ($fromId !== null) {
|
||||||
|
$query->where('id', '>=', $fromId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($toId !== null) {
|
||||||
|
$query->where('id', '<=', $toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
$verified = 0;
|
||||||
|
$previousHash = null;
|
||||||
|
$isFirst = true;
|
||||||
|
|
||||||
|
// If starting from a specific ID, get the previous entry's hash
|
||||||
|
if ($fromId !== null && $fromId > 1) {
|
||||||
|
$previousEntry = McpAuditLog::where('id', '<', $fromId)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
$previousHash = $previousEntry?->entry_hash;
|
||||||
|
$isFirst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
// Process in chunks to avoid memory issues
|
||||||
|
$query->chunk(1000, function ($entries) use (&$issues, &$verified, &$previousHash, &$isFirst) {
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
// Verify hash
|
||||||
|
if (! $entry->verifyHash()) {
|
||||||
|
$issues[] = [
|
||||||
|
'id' => $entry->id,
|
||||||
|
'type' => 'hash_mismatch',
|
||||||
|
'message' => "Entry #{$entry->id}: Hash mismatch - data may have been tampered",
|
||||||
|
'expected' => $entry->computeHash(),
|
||||||
|
'actual' => $entry->entry_hash,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chain link
|
||||||
|
if ($isFirst) {
|
||||||
|
if ($entry->previous_hash !== null) {
|
||||||
|
$issues[] = [
|
||||||
|
'id' => $entry->id,
|
||||||
|
'type' => 'chain_break',
|
||||||
|
'message' => "Entry #{$entry->id}: First entry should have null previous_hash",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$isFirst = false;
|
||||||
|
} else {
|
||||||
|
if ($entry->previous_hash !== $previousHash) {
|
||||||
|
$issues[] = [
|
||||||
|
'id' => $entry->id,
|
||||||
|
'type' => 'chain_break',
|
||||||
|
'message' => "Entry #{$entry->id}: Chain link broken",
|
||||||
|
'expected' => $previousHash,
|
||||||
|
'actual' => $entry->previous_hash,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousHash = $entry->entry_hash;
|
||||||
|
$verified++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => empty($issues),
|
||||||
|
'total' => $total,
|
||||||
|
'verified' => $verified,
|
||||||
|
'issues' => $issues,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs for export.
|
||||||
|
*/
|
||||||
|
public function export(
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?Carbon $from = null,
|
||||||
|
?Carbon $to = null,
|
||||||
|
?string $toolName = null,
|
||||||
|
bool $sensitiveOnly = false
|
||||||
|
): Collection {
|
||||||
|
$query = McpAuditLog::orderBy('id');
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from !== null) {
|
||||||
|
$query->where('created_at', '>=', $from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($to !== null) {
|
||||||
|
$query->where('created_at', '<=', $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($toolName !== null) {
|
||||||
|
$query->where('tool_name', $toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sensitiveOnly) {
|
||||||
|
$query->where('is_sensitive', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->map(fn ($entry) => $entry->toExportArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to CSV format.
|
||||||
|
*/
|
||||||
|
public function exportToCsv(
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?Carbon $from = null,
|
||||||
|
?Carbon $to = null,
|
||||||
|
?string $toolName = null,
|
||||||
|
bool $sensitiveOnly = false
|
||||||
|
): string {
|
||||||
|
$data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly);
|
||||||
|
|
||||||
|
if ($data->isEmpty()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = array_keys($data->first());
|
||||||
|
$output = fopen('php://temp', 'r+');
|
||||||
|
|
||||||
|
fputcsv($output, $headers);
|
||||||
|
|
||||||
|
foreach ($data as $row) {
|
||||||
|
fputcsv($output, array_values($row));
|
||||||
|
}
|
||||||
|
|
||||||
|
rewind($output);
|
||||||
|
$csv = stream_get_contents($output);
|
||||||
|
fclose($output);
|
||||||
|
|
||||||
|
return $csv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to JSON format.
|
||||||
|
*/
|
||||||
|
public function exportToJson(
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?Carbon $from = null,
|
||||||
|
?Carbon $to = null,
|
||||||
|
?string $toolName = null,
|
||||||
|
bool $sensitiveOnly = false
|
||||||
|
): string {
|
||||||
|
$data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly);
|
||||||
|
|
||||||
|
// Include integrity verification in export
|
||||||
|
$verification = $this->verifyChain();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'exported_at' => now()->toIso8601String(),
|
||||||
|
'integrity' => [
|
||||||
|
'valid' => $verification['valid'],
|
||||||
|
'total_entries' => $verification['total'],
|
||||||
|
'verified' => $verification['verified'],
|
||||||
|
'issues_count' => count($verification['issues']),
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'from' => $from?->toIso8601String(),
|
||||||
|
'to' => $to?->toIso8601String(),
|
||||||
|
'tool_name' => $toolName,
|
||||||
|
'sensitive_only' => $sensitiveOnly,
|
||||||
|
],
|
||||||
|
'entries' => $data->toArray(),
|
||||||
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for the audit log.
|
||||||
|
*/
|
||||||
|
public function getStats(?int $workspaceId = null, ?int $days = 30): array
|
||||||
|
{
|
||||||
|
$query = McpAuditLog::query();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($days !== null) {
|
||||||
|
$query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $query->count();
|
||||||
|
$successful = (clone $query)->where('success', true)->count();
|
||||||
|
$failed = (clone $query)->where('success', false)->count();
|
||||||
|
$sensitive = (clone $query)->where('is_sensitive', true)->count();
|
||||||
|
|
||||||
|
$topTools = (clone $query)
|
||||||
|
->select('tool_name', DB::raw('COUNT(*) as count'))
|
||||||
|
->groupBy('tool_name')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit(10)
|
||||||
|
->pluck('count', 'tool_name')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$dailyCounts = (clone $query)
|
||||||
|
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->limit($days ?? 30)
|
||||||
|
->pluck('count', 'date')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'successful' => $successful,
|
||||||
|
'failed' => $failed,
|
||||||
|
'success_rate' => $total > 0 ? round(($successful / $total) * 100, 2) : 0,
|
||||||
|
'sensitive_calls' => $sensitive,
|
||||||
|
'top_tools' => $topTools,
|
||||||
|
'daily_counts' => $dailyCounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a sensitive tool.
|
||||||
|
*/
|
||||||
|
public function registerSensitiveTool(
|
||||||
|
string $toolName,
|
||||||
|
string $reason,
|
||||||
|
array $redactFields = [],
|
||||||
|
bool $requireConsent = false
|
||||||
|
): void {
|
||||||
|
McpSensitiveTool::register($toolName, $reason, $redactFields, $requireConsent);
|
||||||
|
$this->clearSensitiveToolsCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a sensitive tool.
|
||||||
|
*/
|
||||||
|
public function unregisterSensitiveTool(string $toolName): bool
|
||||||
|
{
|
||||||
|
$result = McpSensitiveTool::unregister($toolName);
|
||||||
|
$this->clearSensitiveToolsCache();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered sensitive tools.
|
||||||
|
*/
|
||||||
|
public function getSensitiveTools(): Collection
|
||||||
|
{
|
||||||
|
return McpSensitiveTool::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool requires explicit consent.
|
||||||
|
*/
|
||||||
|
public function requiresConsent(string $toolName): bool
|
||||||
|
{
|
||||||
|
$info = $this->getSensitivityInfo($toolName);
|
||||||
|
|
||||||
|
return $info !== null && ($info['require_explicit_consent'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Protected Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sensitivity info for a tool (cached).
|
||||||
|
*/
|
||||||
|
protected function getSensitivityInfo(string $toolName): ?array
|
||||||
|
{
|
||||||
|
$sensitiveTools = Cache::remember(
|
||||||
|
self::SENSITIVE_TOOLS_CACHE_KEY,
|
||||||
|
self::SENSITIVE_TOOLS_CACHE_TTL,
|
||||||
|
fn () => McpSensitiveTool::all()->keyBy('tool_name')->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! isset($sensitiveTools[$toolName])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tool = $sensitiveTools[$toolName];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'is_sensitive' => true,
|
||||||
|
'reason' => $tool['reason'],
|
||||||
|
'redact_fields' => $tool['redact_fields'] ?? [],
|
||||||
|
'require_explicit_consent' => $tool['require_explicit_consent'] ?? false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive fields from data.
|
||||||
|
*/
|
||||||
|
protected function redactFields(array $data, array $additionalFields = []): array
|
||||||
|
{
|
||||||
|
$fieldsToRedact = array_merge($this->defaultRedactFields, $additionalFields);
|
||||||
|
|
||||||
|
return $this->redactRecursive($data, $fieldsToRedact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively redact fields in nested arrays.
|
||||||
|
*/
|
||||||
|
protected function redactRecursive(array $data, array $fieldsToRedact): array
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$keyLower = strtolower((string) $key);
|
||||||
|
|
||||||
|
// Check if this key should be redacted
|
||||||
|
foreach ($fieldsToRedact as $field) {
|
||||||
|
if (str_contains($keyLower, strtolower($field))) {
|
||||||
|
$data[$key] = '[REDACTED]';
|
||||||
|
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested arrays
|
||||||
|
if (is_array($value)) {
|
||||||
|
$data[$key] = $this->redactRecursive($value, $fieldsToRedact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the sensitive tools cache.
|
||||||
|
*/
|
||||||
|
protected function clearSensitiveToolsCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::SENSITIVE_TOOLS_CACHE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
{{--
|
||||||
|
MCP Audit Log Viewer.
|
||||||
|
|
||||||
|
Displays immutable audit trail for MCP tool executions.
|
||||||
|
Includes integrity verification and compliance export features.
|
||||||
|
--}}
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ __('MCP Audit Log') }}</core:heading>
|
||||||
|
<core:subheading>Immutable audit trail for tool executions with hash chain integrity</core:subheading>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:button wire:click="verifyIntegrity" variant="ghost" icon="shield-check">
|
||||||
|
Verify Integrity
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="openExportModal" icon="arrow-down-tray">
|
||||||
|
Export
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stats Cards --}}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Entries</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{{ number_format($this->stats['total']) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Success Rate</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{{ $this->stats['success_rate'] }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Failed Calls</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{{ number_format($this->stats['failed']) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sensitive Calls</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
|
||||||
|
{{ number_format($this->stats['sensitive_calls']) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search..." icon="magnifying-glass" />
|
||||||
|
</div>
|
||||||
|
<flux:select wire:model.live="tool" placeholder="All tools">
|
||||||
|
<flux:select.option value="">All tools</flux:select.option>
|
||||||
|
@foreach ($this->tools as $toolName)
|
||||||
|
<flux:select.option value="{{ $toolName }}">{{ $toolName }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="workspace" placeholder="All workspaces">
|
||||||
|
<flux:select.option value="">All workspaces</flux:select.option>
|
||||||
|
@foreach ($this->workspaces as $ws)
|
||||||
|
<flux:select.option value="{{ $ws->id }}">{{ $ws->name }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="status" placeholder="All statuses">
|
||||||
|
<flux:select.option value="">All statuses</flux:select.option>
|
||||||
|
<flux:select.option value="success">Success</flux:select.option>
|
||||||
|
<flux:select.option value="failed">Failed</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="sensitivity" placeholder="All sensitivity">
|
||||||
|
<flux:select.option value="">All sensitivity</flux:select.option>
|
||||||
|
<flux:select.option value="sensitive">Sensitive only</flux:select.option>
|
||||||
|
<flux:select.option value="normal">Normal only</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:input type="date" wire:model.live="dateFrom" placeholder="From" />
|
||||||
|
<flux:input type="date" wire:model.live="dateTo" placeholder="To" />
|
||||||
|
@if($search || $tool || $workspace || $status || $sensitivity || $dateFrom || $dateTo)
|
||||||
|
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Audit Log Table --}}
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>ID</flux:table.column>
|
||||||
|
<flux:table.column>Time</flux:table.column>
|
||||||
|
<flux:table.column>Tool</flux:table.column>
|
||||||
|
<flux:table.column>Workspace</flux:table.column>
|
||||||
|
<flux:table.column>Status</flux:table.column>
|
||||||
|
<flux:table.column>Sensitivity</flux:table.column>
|
||||||
|
<flux:table.column>Actor</flux:table.column>
|
||||||
|
<flux:table.column>Duration</flux:table.column>
|
||||||
|
<flux:table.column></flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->entries as $entry)
|
||||||
|
<flux:table.row wire:key="entry-{{ $entry->id }}">
|
||||||
|
<flux:table.cell class="font-mono text-xs text-zinc-500">
|
||||||
|
#{{ $entry->id }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-sm text-zinc-500 whitespace-nowrap">
|
||||||
|
{{ $entry->created_at->format('M j, Y H:i:s') }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="font-medium text-zinc-900 dark:text-white">{{ $entry->tool_name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ $entry->server_id }}</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-sm">
|
||||||
|
@if($entry->workspace)
|
||||||
|
{{ $entry->workspace->name }}
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge size="sm" :color="$entry->success ? 'green' : 'red'">
|
||||||
|
{{ $entry->success ? 'Success' : 'Failed' }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($entry->is_sensitive)
|
||||||
|
<flux:badge size="sm" color="amber" icon="exclamation-triangle">
|
||||||
|
Sensitive
|
||||||
|
</flux:badge>
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400 text-xs">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-sm">
|
||||||
|
{{ $entry->getActorDisplay() }}
|
||||||
|
@if($entry->actor_ip)
|
||||||
|
<div class="text-xs text-zinc-400">{{ $entry->actor_ip }}</div>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-sm text-zinc-500">
|
||||||
|
{{ $entry->getDurationForHumans() }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:button wire:click="viewEntry({{ $entry->id }})" variant="ghost" size="xs" icon="eye">
|
||||||
|
View
|
||||||
|
</flux:button>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="9">
|
||||||
|
<div class="flex flex-col items-center py-12">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
|
||||||
|
<flux:icon name="document-magnifying-glass" class="size-8 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="lg">No audit entries found</flux:heading>
|
||||||
|
<flux:subheading class="mt-1">Audit logs will appear here as tools are executed.</flux:subheading>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->entries->hasPages())
|
||||||
|
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
{{ $this->entries->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Entry Detail Modal --}}
|
||||||
|
@if($this->selectedEntry)
|
||||||
|
<flux:modal wire:model="selectedEntryId" name="entry-detail" class="max-w-3xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:heading size="lg">Audit Entry #{{ $this->selectedEntry->id }}</flux:heading>
|
||||||
|
<flux:button wire:click="closeEntryDetail" variant="ghost" icon="x-mark" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Integrity Status --}}
|
||||||
|
@php
|
||||||
|
$integrity = $this->selectedEntry->getIntegrityStatus();
|
||||||
|
@endphp
|
||||||
|
<div class="rounded-lg p-4 {{ $integrity['valid'] ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20' }}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:icon name="{{ $integrity['valid'] ? 'shield-check' : 'shield-exclamation' }}"
|
||||||
|
class="size-5 {{ $integrity['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}" />
|
||||||
|
<span class="font-medium {{ $integrity['valid'] ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300' }}">
|
||||||
|
{{ $integrity['valid'] ? 'Integrity Verified' : 'Integrity Issues Detected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if(!$integrity['valid'])
|
||||||
|
<ul class="mt-2 text-sm text-red-600 dark:text-red-400 list-disc list-inside">
|
||||||
|
@foreach($integrity['issues'] as $issue)
|
||||||
|
<li>{{ $issue }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Entry Details --}}
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Tool</div>
|
||||||
|
<div class="mt-1 font-medium">{{ $this->selectedEntry->tool_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Server</div>
|
||||||
|
<div class="mt-1">{{ $this->selectedEntry->server_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Timestamp</div>
|
||||||
|
<div class="mt-1">{{ $this->selectedEntry->created_at->format('Y-m-d H:i:s.u') }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Duration</div>
|
||||||
|
<div class="mt-1">{{ $this->selectedEntry->getDurationForHumans() }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Status</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<flux:badge :color="$this->selectedEntry->success ? 'green' : 'red'">
|
||||||
|
{{ $this->selectedEntry->success ? 'Success' : 'Failed' }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Actor</div>
|
||||||
|
<div class="mt-1">{{ $this->selectedEntry->getActorDisplay() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->selectedEntry->is_sensitive)
|
||||||
|
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||||
|
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||||
|
<span class="font-medium">Sensitive Tool</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{{ $this->selectedEntry->sensitivity_reason ?? 'This tool is flagged as sensitive.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($this->selectedEntry->error_message)
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Error</div>
|
||||||
|
<div class="mt-1 rounded-lg bg-red-50 p-3 dark:bg-red-900/20">
|
||||||
|
@if($this->selectedEntry->error_code)
|
||||||
|
<div class="font-mono text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ $this->selectedEntry->error_code }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="text-sm text-red-700 dark:text-red-300">
|
||||||
|
{{ $this->selectedEntry->error_message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($this->selectedEntry->input_params)
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Input Parameters</div>
|
||||||
|
<pre class="mt-1 overflow-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">{{ json_encode($this->selectedEntry->input_params, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($this->selectedEntry->output_summary)
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Output Summary</div>
|
||||||
|
<pre class="mt-1 overflow-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">{{ json_encode($this->selectedEntry->output_summary, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Hash Chain Info --}}
|
||||||
|
<div class="border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Hash Chain</div>
|
||||||
|
<div class="space-y-2 font-mono text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-zinc-500">Entry Hash:</span>
|
||||||
|
<span class="text-zinc-700 dark:text-zinc-300 break-all">{{ $this->selectedEntry->entry_hash }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-zinc-500">Previous Hash:</span>
|
||||||
|
<span class="text-zinc-700 dark:text-zinc-300 break-all">{{ $this->selectedEntry->previous_hash ?? '(first entry)' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Integrity Verification Modal --}}
|
||||||
|
@if($showIntegrityModal && $integrityStatus)
|
||||||
|
<flux:modal wire:model="showIntegrityModal" name="integrity-modal" class="max-w-lg">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:heading size="lg">Integrity Verification</flux:heading>
|
||||||
|
<flux:button wire:click="closeIntegrityModal" variant="ghost" icon="x-mark" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg p-6 {{ $integrityStatus['valid'] ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20' }}">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<flux:icon name="{{ $integrityStatus['valid'] ? 'shield-check' : 'shield-exclamation' }}"
|
||||||
|
class="size-10 {{ $integrityStatus['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}" />
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold {{ $integrityStatus['valid'] ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300' }}">
|
||||||
|
{{ $integrityStatus['valid'] ? 'Audit Log Verified' : 'Integrity Issues Detected' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm {{ $integrityStatus['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}">
|
||||||
|
{{ number_format($integrityStatus['verified']) }} of {{ number_format($integrityStatus['total']) }} entries verified
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(!$integrityStatus['valid'] && !empty($integrityStatus['issues']))
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Issues Found:</div>
|
||||||
|
<div class="max-h-60 overflow-auto rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
@foreach($integrityStatus['issues'] as $issue)
|
||||||
|
<div class="border-b border-red-100 p-3 last:border-0 dark:border-red-900">
|
||||||
|
<div class="font-medium text-red-700 dark:text-red-300">
|
||||||
|
Entry #{{ $issue['id'] }}: {{ $issue['type'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ $issue['message'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<flux:button wire:click="closeIntegrityModal" variant="primary">
|
||||||
|
Close
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Export Modal --}}
|
||||||
|
@if($showExportModal)
|
||||||
|
<flux:modal wire:model="showExportModal" name="export-modal" class="max-w-md">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:heading size="lg">Export Audit Log</flux:heading>
|
||||||
|
<flux:button wire:click="closeExportModal" variant="ghost" icon="x-mark" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Export the audit log with current filters applied. The export includes integrity verification metadata.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:label>Export Format</flux:label>
|
||||||
|
<flux:select wire:model="exportFormat">
|
||||||
|
<flux:select.option value="json">JSON (with integrity metadata)</flux:select.option>
|
||||||
|
<flux:select.option value="csv">CSV (data only)</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-zinc-100 p-3 text-sm dark:bg-zinc-800">
|
||||||
|
<div class="font-medium text-zinc-700 dark:text-zinc-300">Current Filters:</div>
|
||||||
|
<ul class="mt-1 text-zinc-600 dark:text-zinc-400">
|
||||||
|
@if($tool)
|
||||||
|
<li>Tool: {{ $tool }}</li>
|
||||||
|
@endif
|
||||||
|
@if($workspace)
|
||||||
|
<li>Workspace: {{ $this->workspaces->firstWhere('id', $workspace)?->name }}</li>
|
||||||
|
@endif
|
||||||
|
@if($dateFrom || $dateTo)
|
||||||
|
<li>Date: {{ $dateFrom ?: 'start' }} to {{ $dateTo ?: 'now' }}</li>
|
||||||
|
@endif
|
||||||
|
@if($sensitivity === 'sensitive')
|
||||||
|
<li>Sensitive only</li>
|
||||||
|
@endif
|
||||||
|
@if(!$tool && !$workspace && !$dateFrom && !$dateTo && !$sensitivity)
|
||||||
|
<li>All entries</li>
|
||||||
|
@endif
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<flux:button wire:click="closeExportModal" variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="export" variant="primary" icon="arrow-down-tray">
|
||||||
|
Download
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Mcp\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Core\Mod\Mcp\Models\McpAuditLog;
|
||||||
|
use Core\Mod\Mcp\Services\AuditLogService;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Audit Log Viewer.
|
||||||
|
*
|
||||||
|
* Admin interface for viewing and exporting immutable tool execution logs.
|
||||||
|
* Includes integrity verification and compliance export features.
|
||||||
|
*/
|
||||||
|
#[Title('MCP Audit Log')]
|
||||||
|
#[Layout('hub::admin.layouts.app')]
|
||||||
|
class AuditLogViewer extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $tool = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $workspace = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sensitivity = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $dateFrom = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $dateTo = '';
|
||||||
|
|
||||||
|
public int $perPage = 25;
|
||||||
|
|
||||||
|
public ?int $selectedEntryId = null;
|
||||||
|
|
||||||
|
public ?array $integrityStatus = null;
|
||||||
|
|
||||||
|
public bool $showIntegrityModal = false;
|
||||||
|
|
||||||
|
public bool $showExportModal = false;
|
||||||
|
|
||||||
|
public string $exportFormat = 'json';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function entries(): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = McpAuditLog::query()
|
||||||
|
->with('workspace')
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($this->search) {
|
||||||
|
$query->where(function ($q) {
|
||||||
|
$q->where('tool_name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('server_id', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('session_id', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('error_message', 'like', "%{$this->search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tool) {
|
||||||
|
$query->where('tool_name', $this->tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspace) {
|
||||||
|
$query->where('workspace_id', $this->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->status === 'success') {
|
||||||
|
$query->where('success', true);
|
||||||
|
} elseif ($this->status === 'failed') {
|
||||||
|
$query->where('success', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sensitivity === 'sensitive') {
|
||||||
|
$query->where('is_sensitive', true);
|
||||||
|
} elseif ($this->sensitivity === 'normal') {
|
||||||
|
$query->where('is_sensitive', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->dateFrom) {
|
||||||
|
$query->where('created_at', '>=', Carbon::parse($this->dateFrom)->startOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->dateTo) {
|
||||||
|
$query->where('created_at', '<=', Carbon::parse($this->dateTo)->endOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate($this->perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function workspaces(): Collection
|
||||||
|
{
|
||||||
|
return Workspace::orderBy('name')->get(['id', 'name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function tools(): Collection
|
||||||
|
{
|
||||||
|
return McpAuditLog::query()
|
||||||
|
->select('tool_name')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('tool_name')
|
||||||
|
->pluck('tool_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedEntry(): ?McpAuditLog
|
||||||
|
{
|
||||||
|
if (! $this->selectedEntryId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return McpAuditLog::with('workspace')->find($this->selectedEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
return app(AuditLogService::class)->getStats(
|
||||||
|
workspaceId: $this->workspace ? (int) $this->workspace : null,
|
||||||
|
days: 30
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewEntry(int $id): void
|
||||||
|
{
|
||||||
|
$this->selectedEntryId = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeEntryDetail(): void
|
||||||
|
{
|
||||||
|
$this->selectedEntryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyIntegrity(): void
|
||||||
|
{
|
||||||
|
$this->integrityStatus = app(AuditLogService::class)->verifyChain();
|
||||||
|
$this->showIntegrityModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeIntegrityModal(): void
|
||||||
|
{
|
||||||
|
$this->showIntegrityModal = false;
|
||||||
|
$this->integrityStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openExportModal(): void
|
||||||
|
{
|
||||||
|
$this->showExportModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeExportModal(): void
|
||||||
|
{
|
||||||
|
$this->showExportModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(): StreamedResponse
|
||||||
|
{
|
||||||
|
$auditLogService = app(AuditLogService::class);
|
||||||
|
|
||||||
|
$workspaceId = $this->workspace ? (int) $this->workspace : null;
|
||||||
|
$from = $this->dateFrom ? Carbon::parse($this->dateFrom) : null;
|
||||||
|
$to = $this->dateTo ? Carbon::parse($this->dateTo) : null;
|
||||||
|
$tool = $this->tool ?: null;
|
||||||
|
$sensitiveOnly = $this->sensitivity === 'sensitive';
|
||||||
|
|
||||||
|
if ($this->exportFormat === 'csv') {
|
||||||
|
$content = $auditLogService->exportToCsv($workspaceId, $from, $to, $tool, $sensitiveOnly);
|
||||||
|
$filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.csv';
|
||||||
|
$contentType = 'text/csv';
|
||||||
|
} else {
|
||||||
|
$content = $auditLogService->exportToJson($workspaceId, $from, $to, $tool, $sensitiveOnly);
|
||||||
|
$filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.json';
|
||||||
|
$contentType = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($content) {
|
||||||
|
echo $content;
|
||||||
|
}, $filename, [
|
||||||
|
'Content-Type' => $contentType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->tool = '';
|
||||||
|
$this->workspace = '';
|
||||||
|
$this->status = '';
|
||||||
|
$this->sensitivity = '';
|
||||||
|
$this->dateFrom = '';
|
||||||
|
$this->dateTo = '';
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusBadgeClass(bool $success): string
|
||||||
|
{
|
||||||
|
return $success
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSensitivityBadgeClass(bool $isSensitive): string
|
||||||
|
{
|
||||||
|
return $isSensitive
|
||||||
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('mcp::admin.audit-log-viewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -300,18 +300,15 @@
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- [ ] **GitHub Template Repository** - Create host-uk/core-template ⭐⭐⭐
|
- [x] **GitHub Template Repository** - Created host-uk/core-template
|
||||||
- [ ] Set up base Laravel 12 app
|
- [x] Set up base Laravel 12 app
|
||||||
- [ ] Configure composer.json with Core packages
|
- [x] Configure composer.json with Core packages
|
||||||
- [ ] Update bootstrap/app.php to register providers
|
- [x] Update bootstrap/app.php to register providers
|
||||||
- [ ] Create config/core.php
|
- [x] Create config/core.php
|
||||||
- [ ] Update .env.example with Core variables
|
- [x] Update .env.example with Core variables
|
||||||
- [ ] Write comprehensive README.md
|
- [x] Write comprehensive README.md
|
||||||
- [ ] Enable "Template repository" on GitHub
|
- [x] Test `php artisan core:new` command
|
||||||
- [ ] Tag v1.0.0 release
|
- **Completed:** January 2026
|
||||||
- [ ] Test `php artisan core:new` command
|
|
||||||
- **Estimated effort:** 3-4 hours
|
|
||||||
- **Guide:** See `CREATING-TEMPLATE-REPO.md`
|
|
||||||
- **Command:** `php artisan core:new my-project`
|
- **Command:** `php artisan core:new my-project`
|
||||||
|
|
||||||
- [ ] **CI/CD: Add PHP 8.3 Testing** - Future compatibility
|
- [ ] **CI/CD: Add PHP 8.3 Testing** - Future compatibility
|
||||||
|
|
|
||||||
|
|
@ -196,12 +196,20 @@ class LifecycleEventProvider extends ServiceProvider
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan and wire lazy listeners
|
// Scan and wire lazy listeners
|
||||||
// Website modules are included - they use DomainResolving event to self-register
|
// Start with configured application module paths
|
||||||
$this->scanPaths = [
|
$this->scanPaths = config('core.module_paths', [
|
||||||
app_path('Core'),
|
app_path('Core'),
|
||||||
app_path('Mod'),
|
app_path('Mod'),
|
||||||
app_path('Website'),
|
app_path('Website'),
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
// Add framework's own module paths (works in vendor/ or packages/)
|
||||||
|
$frameworkSrcPath = dirname(__DIR__); // .../src/Core -> .../src
|
||||||
|
$this->scanPaths[] = $frameworkSrcPath.'/Core'; // Core\*\Boot
|
||||||
|
$this->scanPaths[] = $frameworkSrcPath.'/Mod'; // Mod\*\Boot
|
||||||
|
|
||||||
|
// Filter to only existing directories
|
||||||
|
$this->scanPaths = array_filter($this->scanPaths, 'is_dir');
|
||||||
|
|
||||||
$registry = $this->app->make(ModuleRegistry::class);
|
$registry = $this->app->make(ModuleRegistry::class);
|
||||||
$registry->register($this->scanPaths);
|
$registry->register($this->scanPaths);
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ class Boot extends ServiceProvider
|
||||||
\Core\Mod\Tenant\Services\UsageAlertService::class
|
\Core\Mod\Tenant\Services\UsageAlertService::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->app->singleton(
|
||||||
|
\Core\Mod\Tenant\Services\EntitlementWebhookService::class,
|
||||||
|
\Core\Mod\Tenant\Services\EntitlementWebhookService::class
|
||||||
|
);
|
||||||
|
|
||||||
$this->registerBackwardCompatAliases();
|
$this->registerBackwardCompatAliases();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +126,9 @@ class Boot extends ServiceProvider
|
||||||
public function onAdminPanel(AdminPanelBooting $event): void
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
{
|
{
|
||||||
$event->views($this->moduleName, __DIR__.'/View/Blade');
|
$event->views($this->moduleName, __DIR__.'/View/Blade');
|
||||||
|
|
||||||
|
// Admin Livewire components
|
||||||
|
$event->livewire('tenant.admin.entitlement-webhook-manager', View\Modal\Admin\EntitlementWebhookManager::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onApiRoutes(ApiRoutesRegistering $event): void
|
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for entitlement webhook events.
|
||||||
|
*
|
||||||
|
* Defines structure for webhook event types that can be
|
||||||
|
* dispatched to external endpoints when entitlement-related
|
||||||
|
* events occur (usage alerts, package changes, boost expiry).
|
||||||
|
*/
|
||||||
|
interface EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the event name/identifier (e.g., 'limit_warning', 'package_changed').
|
||||||
|
*/
|
||||||
|
public static function name(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the localised event name for display.
|
||||||
|
*/
|
||||||
|
public static function nameLocalised(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the event payload data.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function payload(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable message for this event.
|
||||||
|
*/
|
||||||
|
public function message(): string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Controllers\Api;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhookDelivery;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Core\Mod\Tenant\Services\EntitlementWebhookService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API controller for entitlement webhook management.
|
||||||
|
*
|
||||||
|
* Provides CRUD operations for webhooks and delivery history.
|
||||||
|
*/
|
||||||
|
class EntitlementWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected EntitlementWebhookService $webhookService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List webhooks for the current workspace.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$workspace = $this->resolveWorkspace($request);
|
||||||
|
|
||||||
|
$webhooks = EntitlementWebhook::query()
|
||||||
|
->forWorkspace($workspace)
|
||||||
|
->withCount('deliveries')
|
||||||
|
->latest()
|
||||||
|
->paginate($request->integer('per_page', 25));
|
||||||
|
|
||||||
|
return response()->json($webhooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new webhook.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$workspace = $this->resolveWorkspace($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'url' => ['required', 'url', 'max:2048'],
|
||||||
|
'events' => ['required', 'array', 'min:1'],
|
||||||
|
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
|
||||||
|
'secret' => ['nullable', 'string', 'min:32'],
|
||||||
|
'metadata' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$webhook = $this->webhookService->register(
|
||||||
|
workspace: $workspace,
|
||||||
|
name: $validated['name'],
|
||||||
|
url: $validated['url'],
|
||||||
|
events: $validated['events'],
|
||||||
|
secret: $validated['secret'] ?? null,
|
||||||
|
metadata: $validated['metadata'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Webhook created successfully'),
|
||||||
|
'webhook' => $webhook,
|
||||||
|
'secret' => $webhook->secret, // Return secret on creation only
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific webhook.
|
||||||
|
*/
|
||||||
|
public function show(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$webhook->loadCount('deliveries');
|
||||||
|
$webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'webhook' => $webhook,
|
||||||
|
'available_events' => $this->webhookService->getAvailableEvents(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a webhook.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
|
'url' => ['sometimes', 'url', 'max:2048'],
|
||||||
|
'events' => ['sometimes', 'array', 'min:1'],
|
||||||
|
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
'max_attempts' => ['sometimes', 'integer', 'min:1', 'max:10'],
|
||||||
|
'metadata' => ['sometimes', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$webhook = $this->webhookService->update($webhook, $validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Webhook updated successfully'),
|
||||||
|
'webhook' => $webhook,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a webhook.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$this->webhookService->unregister($webhook);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Webhook deleted successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate webhook secret.
|
||||||
|
*/
|
||||||
|
public function regenerateSecret(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$secret = $webhook->regenerateSecret();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Secret regenerated successfully'),
|
||||||
|
'secret' => $secret,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test webhook.
|
||||||
|
*/
|
||||||
|
public function test(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$delivery = $this->webhookService->testWebhook($webhook);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $delivery->isSucceeded()
|
||||||
|
? __('Test webhook sent successfully')
|
||||||
|
: __('Test webhook failed'),
|
||||||
|
'delivery' => $delivery,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset circuit breaker for a webhook.
|
||||||
|
*/
|
||||||
|
public function resetCircuitBreaker(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$this->webhookService->resetCircuitBreaker($webhook);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Webhook re-enabled successfully'),
|
||||||
|
'webhook' => $webhook->refresh(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery history for a webhook.
|
||||||
|
*/
|
||||||
|
public function deliveries(Request $request, EntitlementWebhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $webhook);
|
||||||
|
|
||||||
|
$deliveries = $webhook->deliveries()
|
||||||
|
->latest('created_at')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return response()->json($deliveries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failed delivery.
|
||||||
|
*/
|
||||||
|
public function retryDelivery(Request $request, EntitlementWebhookDelivery $delivery): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWebhook($request, $delivery->webhook);
|
||||||
|
|
||||||
|
if ($delivery->isSucceeded()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Cannot retry a successful delivery'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$delivery = $this->webhookService->retryDelivery($delivery);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $delivery->isSucceeded()
|
||||||
|
? __('Delivery retried successfully')
|
||||||
|
: __('Delivery retry failed'),
|
||||||
|
'delivery' => $delivery,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available event types.
|
||||||
|
*/
|
||||||
|
public function events(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'events' => $this->webhookService->getAvailableEvents(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the workspace from the request.
|
||||||
|
*/
|
||||||
|
protected function resolveWorkspace(Request $request): Workspace
|
||||||
|
{
|
||||||
|
// First try explicit workspace_id parameter
|
||||||
|
if ($request->has('workspace_id')) {
|
||||||
|
$workspace = Workspace::findOrFail($request->integer('workspace_id'));
|
||||||
|
|
||||||
|
// Verify user has access
|
||||||
|
if (! $request->user()->workspaces->contains($workspace)) {
|
||||||
|
abort(403, 'You do not have access to this workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to user's default workspace
|
||||||
|
return $request->user()->defaultHostWorkspace()
|
||||||
|
?? abort(400, 'No workspace specified and user has no default workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize that the user can access this webhook.
|
||||||
|
*/
|
||||||
|
protected function authorizeWebhook(Request $request, EntitlementWebhook $webhook): void
|
||||||
|
{
|
||||||
|
if (! $request->user()->workspaces->contains($webhook->workspace)) {
|
||||||
|
abort(403, 'You do not have access to this webhook');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -274,6 +274,15 @@ class FeatureSeeder extends Seeder
|
||||||
'reset_type' => Feature::RESET_NONE,
|
'reset_type' => Feature::RESET_NONE,
|
||||||
'sort_order' => 12,
|
'sort_order' => 12,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'code' => 'social.ai_suggestions',
|
||||||
|
'name' => 'AI Content Suggestions',
|
||||||
|
'description' => 'AI-powered caption generation and content improvement',
|
||||||
|
'category' => 'social',
|
||||||
|
'type' => Feature::TYPE_BOOLEAN,
|
||||||
|
'reset_type' => Feature::RESET_NONE,
|
||||||
|
'sort_order' => 13,
|
||||||
|
],
|
||||||
|
|
||||||
// AI features
|
// AI features
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Enums;
|
||||||
|
|
||||||
|
enum WebhookDeliveryStatus: string
|
||||||
|
{
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case SUCCESS = 'success';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Events\Webhook;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Models\Boost;
|
||||||
|
use Core\Mod\Tenant\Models\Feature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a boost is activated for a workspace.
|
||||||
|
*/
|
||||||
|
class BoostActivatedEvent implements EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Workspace $workspace,
|
||||||
|
protected Boost $boost,
|
||||||
|
protected ?Feature $feature = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function name(): string
|
||||||
|
{
|
||||||
|
return 'boost_activated';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nameLocalised(): string
|
||||||
|
{
|
||||||
|
return __('Boost Activated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'workspace_slug' => $this->workspace->slug,
|
||||||
|
'boost' => [
|
||||||
|
'id' => $this->boost->id,
|
||||||
|
'feature_code' => $this->boost->feature_code,
|
||||||
|
'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)),
|
||||||
|
'boost_type' => $this->boost->boost_type,
|
||||||
|
'limit_value' => $this->boost->limit_value,
|
||||||
|
'duration_type' => $this->boost->duration_type,
|
||||||
|
'starts_at' => $this->boost->starts_at?->toIso8601String(),
|
||||||
|
'expires_at' => $this->boost->expires_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(): string
|
||||||
|
{
|
||||||
|
$featureName = $this->feature?->name ?? $this->boost->feature_code;
|
||||||
|
|
||||||
|
return "Boost activated: {$featureName} for workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Events\Webhook;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Models\Boost;
|
||||||
|
use Core\Mod\Tenant\Models\Feature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a boost expires for a workspace.
|
||||||
|
*/
|
||||||
|
class BoostExpiredEvent implements EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Workspace $workspace,
|
||||||
|
protected Boost $boost,
|
||||||
|
protected ?Feature $feature = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function name(): string
|
||||||
|
{
|
||||||
|
return 'boost_expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nameLocalised(): string
|
||||||
|
{
|
||||||
|
return __('Boost Expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'workspace_slug' => $this->workspace->slug,
|
||||||
|
'boost' => [
|
||||||
|
'id' => $this->boost->id,
|
||||||
|
'feature_code' => $this->boost->feature_code,
|
||||||
|
'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)),
|
||||||
|
'boost_type' => $this->boost->boost_type,
|
||||||
|
'limit_value' => $this->boost->limit_value,
|
||||||
|
'consumed_quantity' => $this->boost->consumed_quantity,
|
||||||
|
'duration_type' => $this->boost->duration_type,
|
||||||
|
'expired_at' => $this->boost->expires_at?->toIso8601String() ?? now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(): string
|
||||||
|
{
|
||||||
|
$featureName = $this->feature?->name ?? $this->boost->feature_code;
|
||||||
|
|
||||||
|
return "Boost expired: {$featureName} for workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Events\Webhook;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Models\Feature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when workspace usage reaches 100% of the limit.
|
||||||
|
*/
|
||||||
|
class LimitReachedEvent implements EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Workspace $workspace,
|
||||||
|
protected Feature $feature,
|
||||||
|
protected int $used,
|
||||||
|
protected int $limit
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function name(): string
|
||||||
|
{
|
||||||
|
return 'limit_reached';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nameLocalised(): string
|
||||||
|
{
|
||||||
|
return __('Limit Reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'workspace_slug' => $this->workspace->slug,
|
||||||
|
'feature_code' => $this->feature->code,
|
||||||
|
'feature_name' => $this->feature->name,
|
||||||
|
'used' => $this->used,
|
||||||
|
'limit' => $this->limit,
|
||||||
|
'percentage' => 100,
|
||||||
|
'remaining' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(): string
|
||||||
|
{
|
||||||
|
return "Limit reached: {$this->feature->name} at 100% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Events\Webhook;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Models\Feature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when workspace usage reaches the warning threshold (80%).
|
||||||
|
*/
|
||||||
|
class LimitWarningEvent implements EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Workspace $workspace,
|
||||||
|
protected Feature $feature,
|
||||||
|
protected int $used,
|
||||||
|
protected int $limit,
|
||||||
|
protected int $threshold = 80
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function name(): string
|
||||||
|
{
|
||||||
|
return 'limit_warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nameLocalised(): string
|
||||||
|
{
|
||||||
|
return __('Limit Warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'workspace_slug' => $this->workspace->slug,
|
||||||
|
'feature_code' => $this->feature->code,
|
||||||
|
'feature_name' => $this->feature->name,
|
||||||
|
'used' => $this->used,
|
||||||
|
'limit' => $this->limit,
|
||||||
|
'percentage' => round(($this->used / $this->limit) * 100),
|
||||||
|
'remaining' => max(0, $this->limit - $this->used),
|
||||||
|
'threshold' => $this->threshold,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(): string
|
||||||
|
{
|
||||||
|
$percentage = round(($this->used / $this->limit) * 100);
|
||||||
|
|
||||||
|
return "Usage warning: {$this->feature->name} at {$percentage}% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Events\Webhook;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Models\Package;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a workspace's package changes (upgrade, downgrade, or new assignment).
|
||||||
|
*/
|
||||||
|
class PackageChangedEvent implements EntitlementWebhookEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Workspace $workspace,
|
||||||
|
protected ?Package $previousPackage,
|
||||||
|
protected Package $newPackage,
|
||||||
|
protected string $changeType = 'changed' // 'added', 'changed', 'removed'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function name(): string
|
||||||
|
{
|
||||||
|
return 'package_changed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nameLocalised(): string
|
||||||
|
{
|
||||||
|
return __('Package Changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'workspace_slug' => $this->workspace->slug,
|
||||||
|
'change_type' => $this->changeType,
|
||||||
|
'previous_package' => $this->previousPackage ? [
|
||||||
|
'id' => $this->previousPackage->id,
|
||||||
|
'code' => $this->previousPackage->code,
|
||||||
|
'name' => $this->previousPackage->name,
|
||||||
|
] : null,
|
||||||
|
'new_package' => [
|
||||||
|
'id' => $this->newPackage->id,
|
||||||
|
'code' => $this->newPackage->code,
|
||||||
|
'name' => $this->newPackage->name,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(): string
|
||||||
|
{
|
||||||
|
if ($this->changeType === 'added') {
|
||||||
|
return "Package added: {$this->newPackage->name} assigned to workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->changeType === 'removed') {
|
||||||
|
return "Package removed from workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->previousPackage?->name ?? 'none';
|
||||||
|
|
||||||
|
return "Package changed: {$from} to {$this->newPackage->name} for workspace {$this->workspace->name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Jobs;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job to dispatch entitlement webhook deliveries asynchronously.
|
||||||
|
*
|
||||||
|
* Handles retry logic with exponential backoff.
|
||||||
|
*/
|
||||||
|
class DispatchEntitlementWebhook implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds to wait before retrying.
|
||||||
|
*
|
||||||
|
* @var array<int>
|
||||||
|
*/
|
||||||
|
public array $backoff = [60, 300, 900]; // 1min, 5min, 15min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $webhookId,
|
||||||
|
public string $eventName,
|
||||||
|
public array $eventPayload
|
||||||
|
) {
|
||||||
|
$this->onQueue('webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::find($this->webhookId);
|
||||||
|
|
||||||
|
if (! $webhook) {
|
||||||
|
Log::warning('Entitlement webhook not found', ['webhook_id' => $this->webhookId]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if webhook is inactive (circuit breaker may have triggered)
|
||||||
|
if (! $webhook->isActive()) {
|
||||||
|
Log::info('Entitlement webhook is inactive, skipping', [
|
||||||
|
'webhook_id' => $this->webhookId,
|
||||||
|
'event' => $this->eventName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'data' => $this->eventPayload,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Request-Source' => config('app.name'),
|
||||||
|
'User-Agent' => config('app.name').' Entitlement Webhook',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($webhook->secret) {
|
||||||
|
$headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $webhook->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->timeout(10)
|
||||||
|
->post($webhook->url, $data);
|
||||||
|
|
||||||
|
$status = match ($response->status()) {
|
||||||
|
200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS,
|
||||||
|
default => WebhookDeliveryStatus::FAILED,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create delivery record
|
||||||
|
$webhook->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'attempts' => $this->attempts(),
|
||||||
|
'status' => $status,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
'payload' => $data,
|
||||||
|
'response' => $response->json() ?: ['body' => substr($response->body(), 0, 1000)],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === WebhookDeliveryStatus::SUCCESS) {
|
||||||
|
$webhook->resetFailureCount();
|
||||||
|
Log::info('Entitlement webhook delivered successfully', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$webhook->incrementFailureCount();
|
||||||
|
$webhook->updateLastDeliveryStatus($status);
|
||||||
|
|
||||||
|
Log::warning('Entitlement webhook delivery failed', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
'response' => substr($response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Throw exception to trigger retry
|
||||||
|
throw new \RuntimeException("Webhook returned {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook->updateLastDeliveryStatus($status);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$webhook->incrementFailureCount();
|
||||||
|
$webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED);
|
||||||
|
|
||||||
|
// Create failure delivery record
|
||||||
|
$webhook->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'attempts' => $this->attempts(),
|
||||||
|
'status' => WebhookDeliveryStatus::FAILED,
|
||||||
|
'payload' => $data,
|
||||||
|
'response' => ['error' => $e->getMessage()],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::error('Entitlement webhook dispatch exception', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle job failure after all retries exhausted.
|
||||||
|
*/
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::find($this->webhookId);
|
||||||
|
|
||||||
|
Log::error('Entitlement webhook job failed permanently', [
|
||||||
|
'webhook_id' => $this->webhookId,
|
||||||
|
'event' => $this->eventName,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'circuit_broken' => $webhook?->isCircuitBroken() ?? false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags that should be assigned to the job.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function tags(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'entitlement-webhook',
|
||||||
|
"webhook:{$this->webhookId}",
|
||||||
|
"event:{$this->eventName}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -297,4 +297,49 @@ return [
|
||||||
'boosts_expired' => ':count boost(s) have expired.',
|
'boosts_expired' => ':count boost(s) have expired.',
|
||||||
'usage_reset' => 'Usage counters have been reset for the new billing period.',
|
'usage_reset' => 'Usage counters have been reset for the new billing period.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Entitlement Webhooks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'webhooks' => [
|
||||||
|
'events' => [
|
||||||
|
'limit_warning' => 'Limit Warning',
|
||||||
|
'limit_reached' => 'Limit Reached',
|
||||||
|
'package_changed' => 'Package Changed',
|
||||||
|
'boost_activated' => 'Boost Activated',
|
||||||
|
'boost_expired' => 'Boost Expired',
|
||||||
|
],
|
||||||
|
'messages' => [
|
||||||
|
'created' => 'Webhook created successfully.',
|
||||||
|
'updated' => 'Webhook updated successfully.',
|
||||||
|
'deleted' => 'Webhook deleted successfully.',
|
||||||
|
'test_success' => 'Test webhook sent successfully.',
|
||||||
|
'test_failed' => 'Test webhook failed.',
|
||||||
|
'secret_regenerated' => 'Secret regenerated successfully.',
|
||||||
|
'circuit_reset' => 'Webhook re-enabled and failure count reset.',
|
||||||
|
'retry_success' => 'Delivery retried successfully.',
|
||||||
|
'retry_failed' => 'Retry failed.',
|
||||||
|
],
|
||||||
|
'labels' => [
|
||||||
|
'name' => 'Name',
|
||||||
|
'url' => 'URL',
|
||||||
|
'events' => 'Events',
|
||||||
|
'status' => 'Status',
|
||||||
|
'active' => 'Active',
|
||||||
|
'inactive' => 'Inactive',
|
||||||
|
'circuit_broken' => 'Circuit Broken',
|
||||||
|
'secret' => 'Secret',
|
||||||
|
'max_attempts' => 'Max Retry Attempts',
|
||||||
|
'deliveries' => 'Deliveries',
|
||||||
|
],
|
||||||
|
'descriptions' => [
|
||||||
|
'url' => 'The endpoint that will receive webhook POST requests.',
|
||||||
|
'max_attempts' => 'Number of times to retry failed deliveries (1-10).',
|
||||||
|
'inactive' => 'Inactive webhooks will not receive any events.',
|
||||||
|
'secret' => 'Use this secret to verify webhook signatures. The signature is sent in the X-Signature header and is a HMAC-SHA256 hash of the JSON payload.',
|
||||||
|
'save_secret' => 'Save this secret now. It will not be shown again.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Entitlement webhooks for notifying external systems about usage events.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('entitlement_webhooks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('url', 2048);
|
||||||
|
$table->text('secret')->nullable(); // Encrypted HMAC secret
|
||||||
|
$table->json('events'); // Array of subscribed event types
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedTinyInteger('max_attempts')->default(3);
|
||||||
|
$table->string('last_delivery_status')->nullable(); // pending, success, failed
|
||||||
|
$table->timestamp('last_triggered_at')->nullable();
|
||||||
|
$table->unsignedInteger('failure_count')->default(0);
|
||||||
|
$table->json('metadata')->nullable(); // Additional configuration
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'is_active'], 'ent_wh_ws_active_idx');
|
||||||
|
$table->index('uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('entitlement_webhook_deliveries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid');
|
||||||
|
$table->foreignId('webhook_id')
|
||||||
|
->constrained('entitlement_webhooks')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->string('event'); // Event name: limit_warning, limit_reached, etc.
|
||||||
|
$table->unsignedTinyInteger('attempts')->default(1);
|
||||||
|
$table->string('status'); // pending, success, failed
|
||||||
|
$table->unsignedSmallInteger('http_status')->nullable();
|
||||||
|
$table->timestamp('resend_at')->nullable();
|
||||||
|
$table->boolean('resent_manually')->default(false);
|
||||||
|
$table->json('payload');
|
||||||
|
$table->json('response')->nullable();
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
|
||||||
|
$table->index(['webhook_id', 'status'], 'ent_wh_del_wh_status_idx');
|
||||||
|
$table->index(['webhook_id', 'created_at'], 'ent_wh_del_wh_created_idx');
|
||||||
|
$table->index('uuid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('entitlement_webhook_deliveries');
|
||||||
|
Schema::dropIfExists('entitlement_webhooks');
|
||||||
|
}
|
||||||
|
};
|
||||||
245
packages/core-php/src/Mod/Tenant/Models/EntitlementWebhook.php
Normal file
245
packages/core-php/src/Mod/Tenant/Models/EntitlementWebhook.php
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook configuration for entitlement events.
|
||||||
|
*
|
||||||
|
* Allows external systems to receive notifications about
|
||||||
|
* usage alerts, package changes, and boost activity.
|
||||||
|
*/
|
||||||
|
class EntitlementWebhook extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'entitlement_webhooks';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid',
|
||||||
|
'workspace_id',
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'secret',
|
||||||
|
'events',
|
||||||
|
'is_active',
|
||||||
|
'max_attempts',
|
||||||
|
'last_delivery_status',
|
||||||
|
'last_triggered_at',
|
||||||
|
'failure_count',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'events' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'max_attempts' => 'integer',
|
||||||
|
'last_delivery_status' => WebhookDeliveryStatus::class,
|
||||||
|
'last_triggered_at' => 'datetime',
|
||||||
|
'failure_count' => 'integer',
|
||||||
|
'secret' => 'encrypted',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available webhook event types.
|
||||||
|
*/
|
||||||
|
public const EVENTS = [
|
||||||
|
'limit_warning',
|
||||||
|
'limit_reached',
|
||||||
|
'package_changed',
|
||||||
|
'boost_activated',
|
||||||
|
'boost_expired',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum consecutive failures before auto-disable (circuit breaker).
|
||||||
|
*/
|
||||||
|
public const MAX_FAILURES = 5;
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (self $webhook) {
|
||||||
|
if (empty($webhook->uuid)) {
|
||||||
|
$webhook->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliveries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EntitlementWebhookDelivery::class, 'webhook_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForEvent(Builder $query, string $event): Builder
|
||||||
|
{
|
||||||
|
return $query->whereJsonContains('events', $event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
|
||||||
|
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// State checks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->is_active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasEvent(string $event): bool
|
||||||
|
{
|
||||||
|
return in_array($event, $this->events ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCircuitBroken(): bool
|
||||||
|
{
|
||||||
|
return $this->failure_count >= self::MAX_FAILURES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function incrementFailureCount(): void
|
||||||
|
{
|
||||||
|
$this->increment('failure_count');
|
||||||
|
|
||||||
|
// Auto-disable after too many failures (circuit breaker)
|
||||||
|
if ($this->failure_count >= self::MAX_FAILURES) {
|
||||||
|
$this->update(['is_active' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFailureCount(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'failure_count' => 0,
|
||||||
|
'last_triggered_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLastDeliveryStatus(WebhookDeliveryStatus $status): void
|
||||||
|
{
|
||||||
|
$this->update(['last_delivery_status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger webhook and create delivery record.
|
||||||
|
*/
|
||||||
|
public function trigger(EntitlementWebhookEvent $event): EntitlementWebhookDelivery
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'event' => $event::name(),
|
||||||
|
'data' => $event->payload(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Request-Source' => config('app.name'),
|
||||||
|
'User-Agent' => config('app.name').' Entitlement Webhook',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->secret) {
|
||||||
|
$headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->timeout(10)
|
||||||
|
->post($this->url, $data);
|
||||||
|
|
||||||
|
$status = match ($response->status()) {
|
||||||
|
200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS,
|
||||||
|
default => WebhookDeliveryStatus::FAILED,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($status === WebhookDeliveryStatus::SUCCESS) {
|
||||||
|
$this->resetFailureCount();
|
||||||
|
} else {
|
||||||
|
$this->incrementFailureCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateLastDeliveryStatus($status);
|
||||||
|
|
||||||
|
return $this->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => $event::name(),
|
||||||
|
'status' => $status,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
'payload' => $data,
|
||||||
|
'response' => $response->json() ?: ['body' => $response->body()],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->incrementFailureCount();
|
||||||
|
$this->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED);
|
||||||
|
|
||||||
|
return $this->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => $event::name(),
|
||||||
|
'status' => WebhookDeliveryStatus::FAILED,
|
||||||
|
'payload' => $data,
|
||||||
|
'response' => ['error' => $e->getMessage()],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new secret for this webhook.
|
||||||
|
*/
|
||||||
|
public function regenerateSecret(): string
|
||||||
|
{
|
||||||
|
$secret = bin2hex(random_bytes(32));
|
||||||
|
$this->update(['secret' => $secret]);
|
||||||
|
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\MassPrunable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record of an entitlement webhook delivery attempt.
|
||||||
|
*
|
||||||
|
* Tracks successful and failed deliveries for debugging
|
||||||
|
* and retry purposes.
|
||||||
|
*/
|
||||||
|
class EntitlementWebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use MassPrunable;
|
||||||
|
|
||||||
|
protected $table = 'entitlement_webhook_deliveries';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'webhook_id',
|
||||||
|
'uuid',
|
||||||
|
'event',
|
||||||
|
'attempts',
|
||||||
|
'status',
|
||||||
|
'http_status',
|
||||||
|
'resend_at',
|
||||||
|
'resent_manually',
|
||||||
|
'payload',
|
||||||
|
'response',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'attempts' => 'integer',
|
||||||
|
'status' => WebhookDeliveryStatus::class,
|
||||||
|
'http_status' => 'integer',
|
||||||
|
'resend_at' => 'datetime',
|
||||||
|
'resent_manually' => 'boolean',
|
||||||
|
'payload' => 'array',
|
||||||
|
'response' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune deliveries older than 30 days.
|
||||||
|
*/
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return static::where('created_at', '<=', Carbon::now()->subMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhook(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EntitlementWebhook::class, 'webhook_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSucceeded(): bool
|
||||||
|
{
|
||||||
|
return $this->status === WebhookDeliveryStatus::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFailed(): bool
|
||||||
|
{
|
||||||
|
return $this->status === WebhookDeliveryStatus::FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->status === WebhookDeliveryStatus::PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAttemptLimitReached(): bool
|
||||||
|
{
|
||||||
|
return $this->attempts >= $this->webhook->max_attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attempt(): void
|
||||||
|
{
|
||||||
|
$this->increment('attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAsResentManually(): void
|
||||||
|
{
|
||||||
|
$this->resent_manually = true;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateResendAt(Carbon|DateTimeInterface|null $datetime = null): void
|
||||||
|
{
|
||||||
|
$this->resend_at = $datetime;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the event name in a human-readable format.
|
||||||
|
*/
|
||||||
|
public function getEventDisplayName(): string
|
||||||
|
{
|
||||||
|
return match ($this->event) {
|
||||||
|
'limit_warning' => 'Limit Warning',
|
||||||
|
'limit_reached' => 'Limit Reached',
|
||||||
|
'package_changed' => 'Package Changed',
|
||||||
|
'boost_activated' => 'Boost Activated',
|
||||||
|
'boost_expired' => 'Boost Expired',
|
||||||
|
'test' => 'Test',
|
||||||
|
default => ucwords(str_replace('_', ' ', $this->event)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status badge colour for display.
|
||||||
|
*/
|
||||||
|
public function getStatusColour(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
WebhookDeliveryStatus::SUCCESS => 'green',
|
||||||
|
WebhookDeliveryStatus::FAILED => 'red',
|
||||||
|
WebhookDeliveryStatus::PENDING => 'amber',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -447,6 +447,14 @@ class Workspace extends Model
|
||||||
return $this->hasMany(\Core\Mod\Api\Models\WebhookEndpoint::class);
|
return $this->hasMany(\Core\Mod\Api\Models\WebhookEndpoint::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entitlement webhooks for this workspace.
|
||||||
|
*/
|
||||||
|
public function entitlementWebhooks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EntitlementWebhook::class);
|
||||||
|
}
|
||||||
|
|
||||||
// Trees for Agents Relationships
|
// Trees for Agents Relationships
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Core\Mod\Api\Controllers\WorkspaceController;
|
use Core\Mod\Api\Controllers\WorkspaceController;
|
||||||
|
use Core\Mod\Tenant\Controllers\Api\EntitlementWebhookController;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
@ -55,3 +56,27 @@ Route::middleware(['api.auth', 'api.scope.enforce'])->prefix('workspaces')->name
|
||||||
Route::get('/current', [WorkspaceController::class, 'current'])->name('current');
|
Route::get('/current', [WorkspaceController::class, 'current'])->name('current');
|
||||||
Route::get('/{workspace}', [WorkspaceController::class, 'show'])->name('show');
|
Route::get('/{workspace}', [WorkspaceController::class, 'show'])->name('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Entitlement Webhooks API (Auth Required)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Webhook management for entitlement events.
|
||||||
|
| Session-based authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::middleware('auth')->prefix('entitlement-webhooks')->name('api.entitlement-webhooks.')->group(function () {
|
||||||
|
Route::get('/', [EntitlementWebhookController::class, 'index'])->name('index');
|
||||||
|
Route::get('/events', [EntitlementWebhookController::class, 'events'])->name('events');
|
||||||
|
Route::post('/', [EntitlementWebhookController::class, 'store'])->name('store');
|
||||||
|
Route::get('/{webhook}', [EntitlementWebhookController::class, 'show'])->name('show');
|
||||||
|
Route::put('/{webhook}', [EntitlementWebhookController::class, 'update'])->name('update');
|
||||||
|
Route::delete('/{webhook}', [EntitlementWebhookController::class, 'destroy'])->name('destroy');
|
||||||
|
Route::post('/{webhook}/test', [EntitlementWebhookController::class, 'test'])->name('test');
|
||||||
|
Route::post('/{webhook}/regenerate-secret', [EntitlementWebhookController::class, 'regenerateSecret'])->name('regenerate-secret');
|
||||||
|
Route::post('/{webhook}/reset-circuit-breaker', [EntitlementWebhookController::class, 'resetCircuitBreaker'])->name('reset-circuit-breaker');
|
||||||
|
Route::get('/{webhook}/deliveries', [EntitlementWebhookController::class, 'deliveries'])->name('deliveries');
|
||||||
|
Route::post('/deliveries/{delivery}/retry', [EntitlementWebhookController::class, 'retryDelivery'])->name('retry-delivery');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,361 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\Services;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||||
|
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\BoostActivatedEvent;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\BoostExpiredEvent;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\LimitReachedEvent;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\LimitWarningEvent;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\PackageChangedEvent;
|
||||||
|
use Core\Mod\Tenant\Jobs\DispatchEntitlementWebhook;
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhookDelivery;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing and dispatching entitlement webhooks.
|
||||||
|
*
|
||||||
|
* Handles webhook registration, event dispatch, payload signing, and delivery tracking.
|
||||||
|
*/
|
||||||
|
class EntitlementWebhookService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Dispatch an event to all matching webhooks for a workspace.
|
||||||
|
*
|
||||||
|
* @param bool $async Whether to dispatch asynchronously via job queue
|
||||||
|
* @return array<int, array{webhook_id: int, success: bool, delivery_id?: int, error?: string}>
|
||||||
|
*/
|
||||||
|
public function dispatch(Workspace $workspace, EntitlementWebhookEvent $event, bool $async = true): array
|
||||||
|
{
|
||||||
|
$eventName = $event::name();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
$webhooks = EntitlementWebhook::query()
|
||||||
|
->forWorkspace($workspace)
|
||||||
|
->active()
|
||||||
|
->forEvent($eventName)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($webhooks as $webhook) {
|
||||||
|
if ($async) {
|
||||||
|
// Dispatch via job for async processing
|
||||||
|
DispatchEntitlementWebhook::dispatch($webhook->id, $eventName, $event->payload());
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'success' => true,
|
||||||
|
'queued' => true,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Synchronous dispatch
|
||||||
|
try {
|
||||||
|
$delivery = $webhook->trigger($event);
|
||||||
|
$results[] = [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'success' => $delivery->isSucceeded(),
|
||||||
|
'delivery_id' => $delivery->id,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Webhook dispatch failed', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'event' => $eventName,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new webhook for a workspace.
|
||||||
|
*/
|
||||||
|
public function register(
|
||||||
|
Workspace $workspace,
|
||||||
|
string $name,
|
||||||
|
string $url,
|
||||||
|
array $events,
|
||||||
|
?string $secret = null,
|
||||||
|
array $metadata = []
|
||||||
|
): EntitlementWebhook {
|
||||||
|
// Generate secret if not provided
|
||||||
|
$secret ??= bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
return EntitlementWebhook::create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'name' => $name,
|
||||||
|
'url' => $url,
|
||||||
|
'secret' => $secret,
|
||||||
|
'events' => array_intersect($events, EntitlementWebhook::EVENTS),
|
||||||
|
'is_active' => true,
|
||||||
|
'max_attempts' => 3,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister (delete) a webhook.
|
||||||
|
*/
|
||||||
|
public function unregister(EntitlementWebhook $webhook): bool
|
||||||
|
{
|
||||||
|
return $webhook->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update webhook configuration.
|
||||||
|
*/
|
||||||
|
public function update(
|
||||||
|
EntitlementWebhook $webhook,
|
||||||
|
array $attributes
|
||||||
|
): EntitlementWebhook {
|
||||||
|
// Filter events to only allowed values
|
||||||
|
if (isset($attributes['events'])) {
|
||||||
|
$attributes['events'] = array_intersect($attributes['events'], EntitlementWebhook::EVENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook->update($attributes);
|
||||||
|
|
||||||
|
return $webhook->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a payload with HMAC-SHA256.
|
||||||
|
*/
|
||||||
|
public function sign(array $payload, string $secret): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', json_encode($payload), $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a webhook signature.
|
||||||
|
*/
|
||||||
|
public function verifySignature(array $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
$expected = $this->sign($payload, $secret);
|
||||||
|
|
||||||
|
return hash_equals($expected, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available event types with descriptions.
|
||||||
|
*
|
||||||
|
* @return array<string, array{name: string, description: string, class: class-string<EntitlementWebhookEvent>}>
|
||||||
|
*/
|
||||||
|
public function getAvailableEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'limit_warning' => [
|
||||||
|
'name' => LimitWarningEvent::nameLocalised(),
|
||||||
|
'description' => __('Triggered when usage reaches 80% or 90% of a feature limit'),
|
||||||
|
'class' => LimitWarningEvent::class,
|
||||||
|
],
|
||||||
|
'limit_reached' => [
|
||||||
|
'name' => LimitReachedEvent::nameLocalised(),
|
||||||
|
'description' => __('Triggered when usage reaches 100% of a feature limit'),
|
||||||
|
'class' => LimitReachedEvent::class,
|
||||||
|
],
|
||||||
|
'package_changed' => [
|
||||||
|
'name' => PackageChangedEvent::nameLocalised(),
|
||||||
|
'description' => __('Triggered when a workspace package is added, changed, or removed'),
|
||||||
|
'class' => PackageChangedEvent::class,
|
||||||
|
],
|
||||||
|
'boost_activated' => [
|
||||||
|
'name' => BoostActivatedEvent::nameLocalised(),
|
||||||
|
'description' => __('Triggered when a boost is activated for a workspace'),
|
||||||
|
'class' => BoostActivatedEvent::class,
|
||||||
|
],
|
||||||
|
'boost_expired' => [
|
||||||
|
'name' => BoostExpiredEvent::nameLocalised(),
|
||||||
|
'description' => __('Triggered when a boost expires'),
|
||||||
|
'class' => BoostExpiredEvent::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event names as a simple array for forms.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getEventOptions(): array
|
||||||
|
{
|
||||||
|
$events = $this->getAvailableEvents();
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach ($events as $key => $event) {
|
||||||
|
$options[$key] = $event['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a webhook by sending a test event.
|
||||||
|
*/
|
||||||
|
public function testWebhook(EntitlementWebhook $webhook): EntitlementWebhookDelivery
|
||||||
|
{
|
||||||
|
$testPayload = [
|
||||||
|
'event' => 'test',
|
||||||
|
'data' => [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'webhook_name' => $webhook->name,
|
||||||
|
'message' => 'This is a test webhook delivery from '.$webhook->workspace->name,
|
||||||
|
'subscribed_events' => $webhook->events,
|
||||||
|
],
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Request-Source' => config('app.name'),
|
||||||
|
'User-Agent' => config('app.name').' Entitlement Webhook',
|
||||||
|
'X-Test-Webhook' => 'true',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($webhook->secret) {
|
||||||
|
$headers['X-Signature'] = $this->sign($testPayload, $webhook->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->timeout(10)
|
||||||
|
->post($webhook->url, $testPayload);
|
||||||
|
|
||||||
|
$status = in_array($response->status(), [200, 201, 202, 204])
|
||||||
|
? WebhookDeliveryStatus::SUCCESS
|
||||||
|
: WebhookDeliveryStatus::FAILED;
|
||||||
|
|
||||||
|
return $webhook->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => 'test',
|
||||||
|
'status' => $status,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
'payload' => $testPayload,
|
||||||
|
'response' => $response->json() ?: ['body' => $response->body()],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $webhook->deliveries()->create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'event' => 'test',
|
||||||
|
'status' => WebhookDeliveryStatus::FAILED,
|
||||||
|
'payload' => $testPayload,
|
||||||
|
'response' => ['error' => $e->getMessage()],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failed delivery.
|
||||||
|
*/
|
||||||
|
public function retryDelivery(EntitlementWebhookDelivery $delivery): EntitlementWebhookDelivery
|
||||||
|
{
|
||||||
|
$webhook = $delivery->webhook;
|
||||||
|
|
||||||
|
if (! $webhook->isActive()) {
|
||||||
|
throw new \RuntimeException('Cannot retry delivery for inactive webhook');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $delivery->payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Request-Source' => config('app.name'),
|
||||||
|
'User-Agent' => config('app.name').' Entitlement Webhook',
|
||||||
|
'X-Retry-Attempt' => (string) ($delivery->attempts + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($webhook->secret) {
|
||||||
|
$headers['X-Signature'] = $this->sign($payload, $webhook->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->timeout(10)
|
||||||
|
->post($webhook->url, $payload);
|
||||||
|
|
||||||
|
$status = in_array($response->status(), [200, 201, 202, 204])
|
||||||
|
? WebhookDeliveryStatus::SUCCESS
|
||||||
|
: WebhookDeliveryStatus::FAILED;
|
||||||
|
|
||||||
|
$delivery->update([
|
||||||
|
'attempts' => $delivery->attempts + 1,
|
||||||
|
'status' => $status,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
'response' => $response->json() ?: ['body' => $response->body()],
|
||||||
|
'resent_manually' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === WebhookDeliveryStatus::SUCCESS) {
|
||||||
|
$webhook->resetFailureCount();
|
||||||
|
} else {
|
||||||
|
$webhook->incrementFailureCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook->updateLastDeliveryStatus($status);
|
||||||
|
|
||||||
|
return $delivery;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$delivery->update([
|
||||||
|
'attempts' => $delivery->attempts + 1,
|
||||||
|
'status' => WebhookDeliveryStatus::FAILED,
|
||||||
|
'response' => ['error' => $e->getMessage()],
|
||||||
|
'resent_manually' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$webhook->incrementFailureCount();
|
||||||
|
$webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED);
|
||||||
|
|
||||||
|
return $delivery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enable a circuit-broken webhook after fixing the issue.
|
||||||
|
*/
|
||||||
|
public function resetCircuitBreaker(EntitlementWebhook $webhook): void
|
||||||
|
{
|
||||||
|
$webhook->update([
|
||||||
|
'is_active' => true,
|
||||||
|
'failure_count' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhooks for a workspace.
|
||||||
|
*/
|
||||||
|
public function getWebhooksForWorkspace(Workspace $workspace): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return EntitlementWebhook::query()
|
||||||
|
->forWorkspace($workspace)
|
||||||
|
->with(['deliveries' => fn ($q) => $q->latest('created_at')->limit(5)])
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery history for a webhook.
|
||||||
|
*/
|
||||||
|
public function getDeliveryHistory(EntitlementWebhook $webhook, int $limit = 50): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return $webhook->deliveries()
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Tenant\Services;
|
namespace Core\Mod\Tenant\Services;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\LimitReachedEvent;
|
||||||
|
use Core\Mod\Tenant\Events\Webhook\LimitWarningEvent;
|
||||||
use Core\Mod\Tenant\Models\Feature;
|
use Core\Mod\Tenant\Models\Feature;
|
||||||
use Core\Mod\Tenant\Models\UsageAlertHistory;
|
use Core\Mod\Tenant\Models\UsageAlertHistory;
|
||||||
use Core\Mod\Tenant\Models\User;
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
|
@ -25,7 +27,8 @@ use Illuminate\Support\Facades\Log;
|
||||||
class UsageAlertService
|
class UsageAlertService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected EntitlementService $entitlementService
|
protected EntitlementService $entitlementService,
|
||||||
|
protected ?EntitlementWebhookService $webhookService = null
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,6 +234,42 @@ class UsageAlertService
|
||||||
'user_id' => $owner->id,
|
'user_id' => $owner->id,
|
||||||
'user_email' => $owner->email,
|
'user_email' => $owner->email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Dispatch webhook event
|
||||||
|
$this->dispatchWebhook($workspace, $feature, $threshold, $used, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch webhook event for usage alert.
|
||||||
|
*/
|
||||||
|
protected function dispatchWebhook(
|
||||||
|
Workspace $workspace,
|
||||||
|
Feature $feature,
|
||||||
|
int $threshold,
|
||||||
|
int $used,
|
||||||
|
int $limit
|
||||||
|
): void {
|
||||||
|
// Lazy load webhook service if not injected
|
||||||
|
$webhookService = $this->webhookService ?? app(EntitlementWebhookService::class);
|
||||||
|
|
||||||
|
// Create appropriate event based on threshold
|
||||||
|
if ($threshold === UsageAlertHistory::THRESHOLD_LIMIT) {
|
||||||
|
$event = new LimitReachedEvent($workspace, $feature, $used, $limit);
|
||||||
|
} else {
|
||||||
|
$event = new LimitWarningEvent($workspace, $feature, $used, $limit, $threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to all matching webhooks (async)
|
||||||
|
try {
|
||||||
|
$webhookService->dispatch($workspace, $event);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to dispatch usage alert webhook', [
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'feature_code' => $feature->code,
|
||||||
|
'threshold' => $threshold,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
<div>
|
||||||
|
{{-- Stats --}}
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<x-flux::card>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<x-flux::icon name="webhook" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">{{ number_format($this->stats['total']) }}</div>
|
||||||
|
<div class="text-sm text-zinc-500">Total Webhooks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-flux::card>
|
||||||
|
|
||||||
|
<x-flux::card>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<x-flux::icon name="check-circle" class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">{{ number_format($this->stats['active']) }}</div>
|
||||||
|
<div class="text-sm text-zinc-500">Active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-flux::card>
|
||||||
|
|
||||||
|
<x-flux::card>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-red-100 p-3 dark:bg-red-900/30">
|
||||||
|
<x-flux::icon name="exclamation-triangle" class="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">{{ number_format($this->stats['circuit_broken']) }}</div>
|
||||||
|
<div class="text-sm text-zinc-500">Circuit Broken</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-flux::card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Message --}}
|
||||||
|
@if($message)
|
||||||
|
<div class="mb-4">
|
||||||
|
<x-flux::alert :variant="$messageType === 'error' ? 'danger' : 'success'" dismissible wire:click="clearMessage">
|
||||||
|
{{ $message }}
|
||||||
|
</x-flux::alert>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<x-flux::card class="mb-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<x-flux::input
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search by name or URL..."
|
||||||
|
icon="magnifying-glass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-48">
|
||||||
|
<x-flux::select wire:model.live="workspaceId">
|
||||||
|
<option value="">All Workspaces</option>
|
||||||
|
@foreach($this->workspaces as $workspace)
|
||||||
|
<option value="{{ $workspace->id }}">{{ $workspace->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</x-flux::select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-40">
|
||||||
|
<x-flux::select wire:model.live="statusFilter">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="circuit_broken">Circuit Broken</option>
|
||||||
|
</x-flux::select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-flux::button wire:click="create" variant="primary">
|
||||||
|
<x-flux::icon name="plus" class="mr-2 h-4 w-4" />
|
||||||
|
New Webhook
|
||||||
|
</x-flux::button>
|
||||||
|
</div>
|
||||||
|
</x-flux::card>
|
||||||
|
|
||||||
|
{{-- Webhooks Table --}}
|
||||||
|
<x-flux::card>
|
||||||
|
<x-flux::table>
|
||||||
|
<x-flux::table.head>
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.header>Webhook</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Workspace</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Events</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Status</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Deliveries</x-flux::table.header>
|
||||||
|
<x-flux::table.header class="text-right">Actions</x-flux::table.header>
|
||||||
|
</x-flux::table.row>
|
||||||
|
</x-flux::table.head>
|
||||||
|
<x-flux::table.body>
|
||||||
|
@forelse($this->webhooks as $webhook)
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ $webhook->name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500 truncate max-w-xs" title="{{ $webhook->url }}">
|
||||||
|
{{ $webhook->url }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<span class="text-sm">{{ $webhook->workspace?->name ?? 'N/A' }}</span>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
@foreach($webhook->events as $event)
|
||||||
|
<x-flux::badge size="sm" color="purple">{{ $event }}</x-flux::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
@if($webhook->isCircuitBroken())
|
||||||
|
<x-flux::badge color="red">Circuit Broken</x-flux::badge>
|
||||||
|
@elseif($webhook->is_active)
|
||||||
|
<x-flux::badge color="green">Active</x-flux::badge>
|
||||||
|
@else
|
||||||
|
<x-flux::badge color="zinc">Inactive</x-flux::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($webhook->last_delivery_status)
|
||||||
|
<div class="mt-1">
|
||||||
|
<x-flux::badge size="sm" :color="$webhook->last_delivery_status->value === 'success' ? 'green' : ($webhook->last_delivery_status->value === 'failed' ? 'red' : 'amber')">
|
||||||
|
Last: {{ ucfirst($webhook->last_delivery_status->value) }}
|
||||||
|
</x-flux::badge>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<button
|
||||||
|
wire:click="viewDeliveries({{ $webhook->id }})"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{{ number_format($webhook->deliveries_count) }} deliveries
|
||||||
|
</button>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell class="text-right">
|
||||||
|
<x-flux::dropdown>
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-flux::button variant="ghost" size="sm">
|
||||||
|
<x-flux::icon name="ellipsis-vertical" class="h-4 w-4" />
|
||||||
|
</x-flux::button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-flux::dropdown.item wire:click="edit({{ $webhook->id }})">
|
||||||
|
<x-flux::icon name="pencil" class="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
|
||||||
|
<x-flux::dropdown.item wire:click="testWebhook({{ $webhook->id }})">
|
||||||
|
<x-flux::icon name="paper-airplane" class="mr-2 h-4 w-4" />
|
||||||
|
Send Test
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
|
||||||
|
<x-flux::dropdown.item wire:click="viewDeliveries({{ $webhook->id }})">
|
||||||
|
<x-flux::icon name="queue-list" class="mr-2 h-4 w-4" />
|
||||||
|
View Deliveries
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
|
||||||
|
<x-flux::dropdown.item wire:click="regenerateSecret({{ $webhook->id }})">
|
||||||
|
<x-flux::icon name="key" class="mr-2 h-4 w-4" />
|
||||||
|
Regenerate Secret
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
|
||||||
|
@if($webhook->isCircuitBroken())
|
||||||
|
<x-flux::dropdown.item wire:click="resetCircuitBreaker({{ $webhook->id }})">
|
||||||
|
<x-flux::icon name="arrow-path" class="mr-2 h-4 w-4" />
|
||||||
|
Reset Circuit Breaker
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-flux::dropdown.divider />
|
||||||
|
|
||||||
|
<x-flux::dropdown.item wire:click="toggleActive({{ $webhook->id }})">
|
||||||
|
@if($webhook->is_active)
|
||||||
|
<x-flux::icon name="pause" class="mr-2 h-4 w-4" />
|
||||||
|
Disable
|
||||||
|
@else
|
||||||
|
<x-flux::icon name="play" class="mr-2 h-4 w-4" />
|
||||||
|
Enable
|
||||||
|
@endif
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
|
||||||
|
<x-flux::dropdown.item
|
||||||
|
wire:click="delete({{ $webhook->id }})"
|
||||||
|
wire:confirm="Are you sure you want to delete this webhook?"
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
<x-flux::icon name="trash" class="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</x-flux::dropdown.item>
|
||||||
|
</x-flux::dropdown>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
</x-flux::table.row>
|
||||||
|
@empty
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.cell colspan="6" class="text-center py-8 text-zinc-500">
|
||||||
|
No webhooks found. Create one to get started.
|
||||||
|
</x-flux::table.cell>
|
||||||
|
</x-flux::table.row>
|
||||||
|
@endforelse
|
||||||
|
</x-flux::table.body>
|
||||||
|
</x-flux::table>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $this->webhooks->links() }}
|
||||||
|
</div>
|
||||||
|
</x-flux::card>
|
||||||
|
|
||||||
|
{{-- Create/Edit Modal --}}
|
||||||
|
<x-flux::modal wire:model="showFormModal" max-width="lg">
|
||||||
|
<x-flux::modal.header>
|
||||||
|
{{ $editingId ? 'Edit Webhook' : 'Create Webhook' }}
|
||||||
|
</x-flux::modal.header>
|
||||||
|
|
||||||
|
<x-flux::modal.body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@if(!$editingId)
|
||||||
|
<x-flux::field>
|
||||||
|
<x-flux::label>Workspace</x-flux::label>
|
||||||
|
<x-flux::select wire:model="workspaceId">
|
||||||
|
<option value="">Select a workspace...</option>
|
||||||
|
@foreach($this->workspaces as $workspace)
|
||||||
|
<option value="{{ $workspace->id }}">{{ $workspace->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</x-flux::select>
|
||||||
|
<x-flux::error name="workspaceId" />
|
||||||
|
</x-flux::field>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-flux::field>
|
||||||
|
<x-flux::label>Name</x-flux::label>
|
||||||
|
<x-flux::input wire:model="name" placeholder="My Webhook" />
|
||||||
|
<x-flux::error name="name" />
|
||||||
|
</x-flux::field>
|
||||||
|
|
||||||
|
<x-flux::field>
|
||||||
|
<x-flux::label>URL</x-flux::label>
|
||||||
|
<x-flux::input wire:model="url" type="url" placeholder="https://example.com/webhook" />
|
||||||
|
<x-flux::error name="url" />
|
||||||
|
<x-flux::description>The endpoint that will receive webhook POST requests.</x-flux::description>
|
||||||
|
</x-flux::field>
|
||||||
|
|
||||||
|
<x-flux::field>
|
||||||
|
<x-flux::label>Events</x-flux::label>
|
||||||
|
<div class="space-y-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||||
|
@foreach($this->availableEvents as $eventKey => $eventInfo)
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<x-flux::checkbox
|
||||||
|
wire:model="events"
|
||||||
|
value="{{ $eventKey }}"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ $eventInfo['name'] }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ $eventInfo['description'] }}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<x-flux::error name="events" />
|
||||||
|
</x-flux::field>
|
||||||
|
|
||||||
|
<x-flux::field>
|
||||||
|
<x-flux::label>Max Retry Attempts</x-flux::label>
|
||||||
|
<x-flux::input wire:model="maxAttempts" type="number" min="1" max="10" />
|
||||||
|
<x-flux::description>Number of times to retry failed deliveries (1-10).</x-flux::description>
|
||||||
|
</x-flux::field>
|
||||||
|
|
||||||
|
<x-flux::field>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<x-flux::checkbox wire:model="isActive" />
|
||||||
|
<span>Active</span>
|
||||||
|
</label>
|
||||||
|
<x-flux::description>Inactive webhooks will not receive any events.</x-flux::description>
|
||||||
|
</x-flux::field>
|
||||||
|
</div>
|
||||||
|
</x-flux::modal.body>
|
||||||
|
|
||||||
|
<x-flux::modal.footer>
|
||||||
|
<x-flux::button wire:click="closeFormModal" variant="ghost">Cancel</x-flux::button>
|
||||||
|
<x-flux::button wire:click="save" variant="primary">
|
||||||
|
{{ $editingId ? 'Update' : 'Create' }}
|
||||||
|
</x-flux::button>
|
||||||
|
</x-flux::modal.footer>
|
||||||
|
</x-flux::modal>
|
||||||
|
|
||||||
|
{{-- Deliveries Modal --}}
|
||||||
|
<x-flux::modal wire:model="showDeliveriesModal" max-width="3xl">
|
||||||
|
<x-flux::modal.header>
|
||||||
|
Delivery History
|
||||||
|
</x-flux::modal.header>
|
||||||
|
|
||||||
|
<x-flux::modal.body>
|
||||||
|
<x-flux::table>
|
||||||
|
<x-flux::table.head>
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.header>Event</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Status</x-flux::table.header>
|
||||||
|
<x-flux::table.header>HTTP</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Attempts</x-flux::table.header>
|
||||||
|
<x-flux::table.header>Time</x-flux::table.header>
|
||||||
|
<x-flux::table.header></x-flux::table.header>
|
||||||
|
</x-flux::table.row>
|
||||||
|
</x-flux::table.head>
|
||||||
|
<x-flux::table.body>
|
||||||
|
@forelse($this->recentDeliveries as $delivery)
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<x-flux::badge color="purple">{{ $delivery->getEventDisplayName() }}</x-flux::badge>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<x-flux::badge :color="$delivery->getStatusColour()">
|
||||||
|
{{ ucfirst($delivery->status->value) }}
|
||||||
|
</x-flux::badge>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
{{ $delivery->http_status ?? '-' }}
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
{{ $delivery->attempts }}
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
<span title="{{ $delivery->created_at }}">
|
||||||
|
{{ $delivery->created_at->diffForHumans() }}
|
||||||
|
</span>
|
||||||
|
</x-flux::table.cell>
|
||||||
|
|
||||||
|
<x-flux::table.cell>
|
||||||
|
@if($delivery->isFailed())
|
||||||
|
<x-flux::button
|
||||||
|
wire:click="retryDelivery({{ $delivery->id }})"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</x-flux::button>
|
||||||
|
@endif
|
||||||
|
</x-flux::table.cell>
|
||||||
|
</x-flux::table.row>
|
||||||
|
@empty
|
||||||
|
<x-flux::table.row>
|
||||||
|
<x-flux::table.cell colspan="6" class="text-center py-8 text-zinc-500">
|
||||||
|
No deliveries yet.
|
||||||
|
</x-flux::table.cell>
|
||||||
|
</x-flux::table.row>
|
||||||
|
@endforelse
|
||||||
|
</x-flux::table.body>
|
||||||
|
</x-flux::table>
|
||||||
|
</x-flux::modal.body>
|
||||||
|
|
||||||
|
<x-flux::modal.footer>
|
||||||
|
<x-flux::button wire:click="closeDeliveriesModal" variant="ghost">Close</x-flux::button>
|
||||||
|
</x-flux::modal.footer>
|
||||||
|
</x-flux::modal>
|
||||||
|
|
||||||
|
{{-- Secret Modal --}}
|
||||||
|
<x-flux::modal wire:model="showSecretModal" max-width="md">
|
||||||
|
<x-flux::modal.header>
|
||||||
|
Webhook Secret
|
||||||
|
</x-flux::modal.header>
|
||||||
|
|
||||||
|
<x-flux::modal.body>
|
||||||
|
<x-flux::alert variant="warning" class="mb-4">
|
||||||
|
Save this secret now. It will not be shown again.
|
||||||
|
</x-flux::alert>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-zinc-100 p-4 font-mono text-sm dark:bg-zinc-800 break-all">
|
||||||
|
{{ $displaySecret }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm text-zinc-500">
|
||||||
|
Use this secret to verify webhook signatures. The signature is sent in the
|
||||||
|
<code class="rounded bg-zinc-100 px-1 dark:bg-zinc-800">X-Signature</code> header
|
||||||
|
and is a HMAC-SHA256 hash of the JSON payload.
|
||||||
|
</p>
|
||||||
|
</x-flux::modal.body>
|
||||||
|
|
||||||
|
<x-flux::modal.footer>
|
||||||
|
<x-flux::button wire:click="closeSecretModal" variant="primary">
|
||||||
|
I've saved the secret
|
||||||
|
</x-flux::button>
|
||||||
|
</x-flux::modal.footer>
|
||||||
|
</x-flux::modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Tenant\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||||
|
use Core\Mod\Tenant\Models\EntitlementWebhookDelivery;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Core\Mod\Tenant\Services\EntitlementWebhookService;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
#[Title('Entitlement Webhooks')]
|
||||||
|
class EntitlementWebhookManager extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
public ?int $workspaceId = null;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $statusFilter = '';
|
||||||
|
|
||||||
|
// Create/Edit modal state
|
||||||
|
public bool $showFormModal = false;
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public string $url = '';
|
||||||
|
|
||||||
|
public array $events = [];
|
||||||
|
|
||||||
|
public bool $isActive = true;
|
||||||
|
|
||||||
|
public int $maxAttempts = 3;
|
||||||
|
|
||||||
|
// Deliveries modal state
|
||||||
|
public bool $showDeliveriesModal = false;
|
||||||
|
|
||||||
|
public ?int $viewingWebhookId = null;
|
||||||
|
|
||||||
|
// Secret modal state
|
||||||
|
public bool $showSecretModal = false;
|
||||||
|
|
||||||
|
public ?string $displaySecret = null;
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
public string $message = '';
|
||||||
|
|
||||||
|
public string $messageType = 'success';
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'workspaceId' => ['except' => null],
|
||||||
|
'statusFilter' => ['except' => ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'url' => 'required|url|max:2048',
|
||||||
|
'events' => 'required|array|min:1',
|
||||||
|
'events.*' => 'string',
|
||||||
|
'isActive' => 'boolean',
|
||||||
|
'maxAttempts' => 'required|integer|min:1|max:10',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades tier required for webhook administration.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatingSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatingWorkspaceId(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function webhooks()
|
||||||
|
{
|
||||||
|
return EntitlementWebhook::query()
|
||||||
|
->with('workspace')
|
||||||
|
->withCount('deliveries')
|
||||||
|
->when($this->workspaceId, fn ($q) => $q->where('workspace_id', $this->workspaceId))
|
||||||
|
->when($this->search, function ($query) {
|
||||||
|
$query->where(function ($q) {
|
||||||
|
$q->where('name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('url', 'like', "%{$this->search}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($this->statusFilter === 'active', fn ($q) => $q->active())
|
||||||
|
->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false))
|
||||||
|
->when($this->statusFilter === 'circuit_broken', fn ($q) => $q->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES))
|
||||||
|
->latest()
|
||||||
|
->paginate(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function workspaces()
|
||||||
|
{
|
||||||
|
return Workspace::query()
|
||||||
|
->select('id', 'name', 'slug')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableEvents(): array
|
||||||
|
{
|
||||||
|
return app(EntitlementWebhookService::class)->getAvailableEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function recentDeliveries()
|
||||||
|
{
|
||||||
|
if (! $this->viewingWebhookId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntitlementWebhookDelivery::query()
|
||||||
|
->where('webhook_id', $this->viewingWebhookId)
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Create/Edit Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->reset(['editingId', 'name', 'url', 'events', 'maxAttempts']);
|
||||||
|
$this->isActive = true;
|
||||||
|
$this->maxAttempts = 3;
|
||||||
|
$this->showFormModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
|
||||||
|
$this->editingId = $webhook->id;
|
||||||
|
$this->name = $webhook->name;
|
||||||
|
$this->url = $webhook->url;
|
||||||
|
$this->events = $webhook->events;
|
||||||
|
$this->isActive = $webhook->is_active;
|
||||||
|
$this->maxAttempts = $webhook->max_attempts;
|
||||||
|
$this->workspaceId = $webhook->workspace_id;
|
||||||
|
$this->showFormModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
// Filter events to only valid ones
|
||||||
|
$validEvents = array_intersect($this->events, EntitlementWebhook::EVENTS);
|
||||||
|
|
||||||
|
if (empty($validEvents)) {
|
||||||
|
$this->addError('events', 'At least one valid event must be selected.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->editingId) {
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($this->editingId);
|
||||||
|
$webhook->update([
|
||||||
|
'name' => $this->name,
|
||||||
|
'url' => $this->url,
|
||||||
|
'events' => $validEvents,
|
||||||
|
'is_active' => $this->isActive,
|
||||||
|
'max_attempts' => $this->maxAttempts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->setMessage('Webhook updated successfully.');
|
||||||
|
} else {
|
||||||
|
if (! $this->workspaceId) {
|
||||||
|
$this->addError('workspaceId', 'Please select a workspace.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::findOrFail($this->workspaceId);
|
||||||
|
$webhook = app(EntitlementWebhookService::class)->register(
|
||||||
|
workspace: $workspace,
|
||||||
|
name: $this->name,
|
||||||
|
url: $this->url,
|
||||||
|
events: $validEvents
|
||||||
|
);
|
||||||
|
|
||||||
|
$webhook->update([
|
||||||
|
'is_active' => $this->isActive,
|
||||||
|
'max_attempts' => $this->maxAttempts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Show the secret to the user
|
||||||
|
$this->displaySecret = $webhook->secret;
|
||||||
|
$this->showSecretModal = true;
|
||||||
|
|
||||||
|
$this->setMessage('Webhook created successfully. Please save the secret below.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showFormModal = false;
|
||||||
|
$this->reset(['editingId', 'name', 'url', 'events']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeFormModal(): void
|
||||||
|
{
|
||||||
|
$this->showFormModal = false;
|
||||||
|
$this->reset(['editingId', 'name', 'url', 'events']);
|
||||||
|
$this->resetValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Action Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function toggleActive(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
$webhook->update(['is_active' => ! $webhook->is_active]);
|
||||||
|
|
||||||
|
$this->setMessage($webhook->is_active ? 'Webhook enabled.' : 'Webhook disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
$webhook->delete();
|
||||||
|
|
||||||
|
$this->setMessage('Webhook deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWebhook(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
$delivery = app(EntitlementWebhookService::class)->testWebhook($webhook);
|
||||||
|
|
||||||
|
if ($delivery->isSucceeded()) {
|
||||||
|
$this->setMessage('Test webhook sent successfully.');
|
||||||
|
} else {
|
||||||
|
$this->setMessage('Test webhook failed. Check delivery history for details.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateSecret(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
$secret = $webhook->regenerateSecret();
|
||||||
|
|
||||||
|
$this->displaySecret = $secret;
|
||||||
|
$this->showSecretModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetCircuitBreaker(int $id): void
|
||||||
|
{
|
||||||
|
$webhook = EntitlementWebhook::findOrFail($id);
|
||||||
|
app(EntitlementWebhookService::class)->resetCircuitBreaker($webhook);
|
||||||
|
|
||||||
|
$this->setMessage('Webhook re-enabled and failure count reset.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Deliveries Modal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function viewDeliveries(int $id): void
|
||||||
|
{
|
||||||
|
$this->viewingWebhookId = $id;
|
||||||
|
$this->showDeliveriesModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeDeliveriesModal(): void
|
||||||
|
{
|
||||||
|
$this->showDeliveriesModal = false;
|
||||||
|
$this->viewingWebhookId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryDelivery(int $deliveryId): void
|
||||||
|
{
|
||||||
|
$delivery = EntitlementWebhookDelivery::findOrFail($deliveryId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = app(EntitlementWebhookService::class)->retryDelivery($delivery);
|
||||||
|
|
||||||
|
if ($result->isSucceeded()) {
|
||||||
|
$this->setMessage('Delivery retried successfully.');
|
||||||
|
} else {
|
||||||
|
$this->setMessage('Retry failed. Check delivery details.', 'error');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->setMessage($e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Secret Modal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function closeSecretModal(): void
|
||||||
|
{
|
||||||
|
$this->showSecretModal = false;
|
||||||
|
$this->displaySecret = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helper Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected function setMessage(string $message, string $type = 'success'): void
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
$this->messageType = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMessage(): void
|
||||||
|
{
|
||||||
|
$this->message = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
$query = EntitlementWebhook::query();
|
||||||
|
|
||||||
|
if ($this->workspaceId) {
|
||||||
|
$query->where('workspace_id', $this->workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $query)->count(),
|
||||||
|
'active' => (clone $query)->where('is_active', true)->count(),
|
||||||
|
'circuit_broken' => (clone $query)->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('tenant::admin.entitlement-webhook-manager')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Entitlement Webhooks']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php $layout->viewContext->mergeIntoNewEnvironment($__env); ?>
|
|
||||||
|
|
||||||
@component($layout->view, $layout->params)
|
|
||||||
@slot($layout->slotOrSection)
|
|
||||||
{!! $content !!}
|
|
||||||
@endslot
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Manually forward slots defined in the Livewire template into the layout component...
|
|
||||||
foreach ($layout->viewContext->slots[-1] ?? [] as $name => $slot) {
|
|
||||||
$__env->slot($name, attributes: $slot->attributes->getAttributes());
|
|
||||||
echo $slot->toHtml();
|
|
||||||
$__env->endSlot();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@endcomponent
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,17 +0,0 @@
|
||||||
<?php $layout->viewContext->mergeIntoNewEnvironment($__env); ?>
|
|
||||||
|
|
||||||
<?php $__env->startComponent($layout->view, $layout->params); ?>
|
|
||||||
<?php $__env->slot($layout->slotOrSection); ?>
|
|
||||||
<?php echo $content; ?>
|
|
||||||
|
|
||||||
<?php $__env->endSlot(); ?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Manually forward slots defined in the Livewire template into the layout component...
|
|
||||||
foreach ($layout->viewContext->slots[-1] ?? [] as $name => $slot) {
|
|
||||||
$__env->slot($name, attributes: $slot->attributes->getAttributes());
|
|
||||||
echo $slot->toHtml();
|
|
||||||
$__env->endSlot();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<?php echo $__env->renderComponent(); ?><?php /**PATH /Users/snider/Code/lab/core-php/storage/framework/views/4943bc92ebba41e8b0e508149542e0ad.blade.php ENDPATH**/ ?>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?php $__env->startSection('title', __('Not Found')); ?>
|
|
||||||
<?php $__env->startSection('code', '404'); ?>
|
|
||||||
<?php $__env->startSection('message', __('Not Found')); ?>
|
|
||||||
|
|
||||||
<?php echo $__env->make('errors::minimal', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH /Users/snider/Code/lab/core-php/vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/views/404.blade.php ENDPATH**/ ?>
|
|
||||||
Loading…
Add table
Reference in a new issue