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' => [
|
||||
// app_path('Core'),
|
||||
// app_path('Mod'),
|
||||
// Application modules (user-created)
|
||||
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.
|
||||
|
||||
## 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
|
||||
# Create new Laravel project
|
||||
composer create-project laravel/laravel my-app
|
||||
cd my-app
|
||||
php artisan core:new my-project
|
||||
cd my-project
|
||||
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
|
||||
composer require host-uk/core
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Core\Events\ConsoleBooting;
|
|||
use Core\Events\McpToolsRegistering;
|
||||
use Core\Mod\Mcp\Events\ToolExecuted;
|
||||
use Core\Mod\Mcp\Listeners\RecordToolExecution;
|
||||
use Core\Mod\Mcp\Services\AuditLogService;
|
||||
use Core\Mod\Mcp\Services\McpQuotaService;
|
||||
use Core\Mod\Mcp\Services\ToolAnalyticsService;
|
||||
use Core\Mod\Mcp\Services\ToolDependencyService;
|
||||
|
|
@ -43,6 +44,7 @@ class Boot extends ServiceProvider
|
|||
$this->app->singleton(ToolAnalyticsService::class);
|
||||
$this->app->singleton(McpQuotaService::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-detail', View\Modal\Admin\ToolAnalyticsDetail::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
|
||||
{
|
||||
$event->command(Console\Commands\McpAgentServerCommand::class);
|
||||
$event->command(Console\Commands\PruneMetricsCommand::class);
|
||||
$event->command(Console\Commands\VerifyAuditLogCommand::class);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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\Playground;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\RequestLog;
|
||||
|
|
@ -52,4 +53,8 @@ Route::prefix('mcp')->name('mcp.')->group(function () {
|
|||
// Single tool analytics detail
|
||||
Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class)
|
||||
->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
|
||||
|
||||
- [ ] **GitHub Template Repository** - Create host-uk/core-template ⭐⭐⭐
|
||||
- [ ] Set up base Laravel 12 app
|
||||
- [ ] Configure composer.json with Core packages
|
||||
- [ ] Update bootstrap/app.php to register providers
|
||||
- [ ] Create config/core.php
|
||||
- [ ] Update .env.example with Core variables
|
||||
- [ ] Write comprehensive README.md
|
||||
- [ ] Enable "Template repository" on GitHub
|
||||
- [ ] Tag v1.0.0 release
|
||||
- [ ] Test `php artisan core:new` command
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- **Guide:** See `CREATING-TEMPLATE-REPO.md`
|
||||
- [x] **GitHub Template Repository** - Created host-uk/core-template
|
||||
- [x] Set up base Laravel 12 app
|
||||
- [x] Configure composer.json with Core packages
|
||||
- [x] Update bootstrap/app.php to register providers
|
||||
- [x] Create config/core.php
|
||||
- [x] Update .env.example with Core variables
|
||||
- [x] Write comprehensive README.md
|
||||
- [x] Test `php artisan core:new` command
|
||||
- **Completed:** January 2026
|
||||
- **Command:** `php artisan core:new my-project`
|
||||
|
||||
- [ ] **CI/CD: Add PHP 8.3 Testing** - Future compatibility
|
||||
|
|
|
|||
|
|
@ -196,12 +196,20 @@ class LifecycleEventProvider extends ServiceProvider
|
|||
});
|
||||
|
||||
// Scan and wire lazy listeners
|
||||
// Website modules are included - they use DomainResolving event to self-register
|
||||
$this->scanPaths = [
|
||||
// Start with configured application module paths
|
||||
$this->scanPaths = config('core.module_paths', [
|
||||
app_path('Core'),
|
||||
app_path('Mod'),
|
||||
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->register($this->scanPaths);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ class Boot extends ServiceProvider
|
|||
\Core\Mod\Tenant\Services\UsageAlertService::class
|
||||
);
|
||||
|
||||
$this->app->singleton(
|
||||
\Core\Mod\Tenant\Services\EntitlementWebhookService::class,
|
||||
\Core\Mod\Tenant\Services\EntitlementWebhookService::class
|
||||
);
|
||||
|
||||
$this->registerBackwardCompatAliases();
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +126,9 @@ class Boot extends ServiceProvider
|
|||
public function onAdminPanel(AdminPanelBooting $event): void
|
||||
{
|
||||
$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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
'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
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
'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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entitlement webhooks for this workspace.
|
||||
*/
|
||||
public function entitlementWebhooks(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntitlementWebhook::class);
|
||||
}
|
||||
|
||||
// Trees for Agents Relationships
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
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('/{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;
|
||||
|
||||
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\UsageAlertHistory;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
|
|
@ -25,7 +27,8 @@ use Illuminate\Support\Facades\Log;
|
|||
class UsageAlertService
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlementService
|
||||
protected EntitlementService $entitlementService,
|
||||
protected ?EntitlementWebhookService $webhookService = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -231,6 +234,42 @@ class UsageAlertService
|
|||
'user_id' => $owner->id,
|
||||
'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