Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
342e8ca82d | ||
|
|
05d82f24ab | ||
|
|
ccf68c96b0 | ||
|
|
fd6092c7cf | ||
|
|
5a1be07c2b | ||
|
|
d2410f50a3 | ||
|
|
d3776d48e3 | ||
|
|
98f48df15d | ||
|
|
98102e510d | ||
|
|
1d8a202bdf | ||
| 8f2590477c | |||
|
|
be304e7b1a | ||
|
|
208cb93c95 |
320 changed files with 4299 additions and 546 deletions
59
.github/workflows/lint-corelint.yml
vendored
Normal file
59
.github/workflows/lint-corelint.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: core-lint pilot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, dev]
|
||||
pull_request:
|
||||
branches: [main, develop, dev]
|
||||
|
||||
# Pilot workflow that runs the core-lint orchestrator (PHPStan + Psalm via the
|
||||
# code/core/lint adapters) ALONGSIDE the existing native phpstan/psalm jobs in
|
||||
# .github/workflows/static-analysis.yml. Both must continue to run for at least
|
||||
# one merge cycle so the parity between native and core-lint outputs can be
|
||||
# diffed before native jobs are removed.
|
||||
#
|
||||
# Spec: plans/code/core/lint/RFC.md §5 (Adapter), §5.4 (Built-in Adapters)
|
||||
|
||||
jobs:
|
||||
core-lint:
|
||||
name: core-lint (pilot)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # Pilot — failures here MUST NOT block PRs.
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
|
||||
- name: Install core-lint
|
||||
run: |
|
||||
go install dappco.re/go/lint/cmd/core-lint@latest || \
|
||||
go install github.com/dappcore/lint/cmd/core-lint@latest
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run core-lint (PHP, JSON for diffing)
|
||||
run: |
|
||||
core-lint run --lang php --ci --output json > core-lint-report.json || true
|
||||
core-lint run --lang php --output text || true
|
||||
|
||||
- name: Upload core-lint report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-lint-report
|
||||
path: core-lint-report.json
|
||||
if-no-files-found: ignore
|
||||
|
|
@ -71,7 +71,6 @@ src/Core/Lang/ # Translation system with ICU + locale fallback chains
|
|||
src/Core/Media/ # Media handling with thumbnail helpers
|
||||
src/Core/Search/ # Search functionality
|
||||
src/Core/Seo/ # SEO utilities
|
||||
src/Core/Service/ # Service discovery and dependency resolution
|
||||
src/Core/Storage/ # Storage with Redis circuit breaker + fallback
|
||||
src/Core/Webhook/ # Webhook system + CronTrigger scheduled action
|
||||
```
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
php "forge.lthn.ai/core/php/pkg/php"
|
||||
php "dappco.re/go/php/pkg/php"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
||||
"laravel/pennant": "^1.0",
|
||||
"livewire/livewire": "^3.0|^4.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Core\Activity\Models\Activity;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -449,7 +451,7 @@ return [
|
|||
// Custom Activity model class (optional).
|
||||
// Set this to use a custom Activity model with additional scopes.
|
||||
// Default: Core\Activity\Models\Activity::class
|
||||
'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class),
|
||||
'activity_model' => env('CORE_ACTIVITY_MODEL', Activity::class),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
23
go.mod
23
go.mod
|
|
@ -1,20 +1,29 @@
|
|||
module forge.lthn.ai/core/php
|
||||
module dappco.re/go/php
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/cli v0.3.7
|
||||
forge.lthn.ai/core/go-i18n v0.1.7
|
||||
forge.lthn.ai/core/go-io v0.1.7
|
||||
dappco.re/go/cli v0.8.0-alpha.1
|
||||
dappco.re/go/i18n v0.8.0-alpha.1
|
||||
dappco.re/go/io v0.8.0-alpha.1
|
||||
github.com/dunglas/frankenphp v1.12.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.3.3 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.6 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/api v0.8.0-alpha.1
|
||||
dappco.re/go/i18n v0.8.0-alpha.1
|
||||
dappco.re/go/io v0.8.0-alpha.1
|
||||
dappco.re/go/log v0.8.0-alpha.1
|
||||
dappco.re/go/process v0.8.0-alpha.1
|
||||
dappco.re/go/scm v0.8.0-alpha.1
|
||||
dappco.re/go/store v0.8.0-alpha.1
|
||||
dappco.re/go/ws v0.8.0-alpha.1
|
||||
dappco.re/go/core v0.8.0-alpha.1 // indirect
|
||||
dappco.re/go/inference v0.8.0-alpha.1 // indirect
|
||||
dappco.re/go/log v0.8.0-alpha.1 // indirect
|
||||
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ parameters:
|
|||
- src/Core/Tests
|
||||
- src/Core/Bouncer/Tests
|
||||
- src/Core/Bouncer/Gate/Tests
|
||||
- src/Core/Service/Tests
|
||||
- src/Core/Front/Tests
|
||||
- src/Mod/Trees
|
||||
# Pending Core\Service module — see plans/code/core/lint/RFC.md migration
|
||||
- src/Core/Cdn/Services/BunnyStorageService.php
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
"dappco.re/go/io"
|
||||
)
|
||||
|
||||
// DefaultMedium is the default filesystem medium used by the php package.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
// CI command flags
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
// - deploy:list: List recent deployments
|
||||
package php
|
||||
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
import "dappco.re/go/cli/pkg/cli"
|
||||
|
||||
// AddCommands registers the 'php' command and all subcommands.
|
||||
func AddCommands(root *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import (
|
|||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
// Deploy command styles (aliases to shared)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package php
|
|||
import (
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
func addPHPPackagesCommands(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// DockerBuildOptions configures Docker image building for PHP projects.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// CoolifyClient is an HTTP client for the Coolify API.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// Environment represents a deployment environment.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// DockerfileConfig holds configuration for generating a Dockerfile.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ package php
|
|||
import (
|
||||
"embed"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// LinkedPackage represents a linked local package.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// Options configures the development server.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
"dappco.re/go/i18n"
|
||||
)
|
||||
|
||||
// FormatOptions configures PHP code formatting.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// Service represents a managed development service.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// TestOptions configures PHP test execution.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"dappco.re/go/io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
|
|||
24
psalm.xml
24
psalm.xml
|
|
@ -19,6 +19,15 @@
|
|||
</errorLevel>
|
||||
</MissingOverrideAttribute>
|
||||
|
||||
<!-- Laravel plugin (Psalm) flags env() calls outside config/. PHPStan
|
||||
ignores the equivalent identifier (larastan.noEnvCallsOutsideOfConfig).
|
||||
Plugin-registered issues use <PluginIssue name="..."> form. -->
|
||||
<PluginIssue name="NoEnvOutsideConfig">
|
||||
<errorLevel type="suppress">
|
||||
<directory name="src" />
|
||||
</errorLevel>
|
||||
</PluginIssue>
|
||||
|
||||
<!-- Suppress optional dependency errors -->
|
||||
<UndefinedClass>
|
||||
<errorLevel type="suppress">
|
||||
|
|
@ -55,9 +64,23 @@
|
|||
<referencedClass name="Core\Tenant\Models\User" />
|
||||
<referencedClass name="Core\Tenant\Services\EntitlementService" />
|
||||
<referencedClass name="Core\Config\Workspace" />
|
||||
<!-- Pending Core\Service module (see plans/code/core/lint/RFC.md) -->
|
||||
<referencedClass name="Core\Service\Contracts\HealthCheckable" />
|
||||
<referencedClass name="Core\Service\HealthCheckResult" />
|
||||
<!-- Pending Front\Client frontage subpackage -->
|
||||
<referencedClass name="Core\Front\Client\Boot" />
|
||||
<!-- Laravel framework classes Psalm CI doesn't always resolve -->
|
||||
<referencedClass name="Illuminate\Foundation\Auth\User" />
|
||||
</errorLevel>
|
||||
</UndefinedClass>
|
||||
|
||||
<!-- Pending Core\Service module — referenced by Cdn BunnyStorageService and app variant -->
|
||||
<MissingDependency>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="src/Core/Cdn" />
|
||||
</errorLevel>
|
||||
</MissingDependency>
|
||||
|
||||
<!-- Suppress false positives from strict type analysis -->
|
||||
<NoValue>
|
||||
<errorLevel type="suppress">
|
||||
|
|
@ -82,7 +105,6 @@
|
|||
<directory name="src/Core/Input/Tests" />
|
||||
<directory name="src/Core/Bouncer/Tests" />
|
||||
<directory name="src/Core/Bouncer/Gate/Tests" />
|
||||
<directory name="src/Core/Service/Tests" />
|
||||
<directory name="src/Core/Front/Tests" />
|
||||
<directory name="src/Mod/Trees" />
|
||||
</ignoreFiles>
|
||||
|
|
|
|||
57
src/Core/Actions/CLAUDE.md
Normal file
57
src/Core/Actions/CLAUDE.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Actions
|
||||
|
||||
Single-purpose business logic pattern with scheduling support.
|
||||
|
||||
## What It Does
|
||||
|
||||
Provides the `Action` trait for extracting business logic from controllers/components into focused, testable classes. Each action does one thing via a `handle()` method and gets a static `run()` shortcut that resolves dependencies from the container.
|
||||
|
||||
Also provides attribute-driven scheduling: annotate an Action with `#[Scheduled]` and it gets wired into Laravel's scheduler automatically.
|
||||
|
||||
## Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Action` (trait) | Adds `static run(...$args)` that resolves via container and calls `handle()` |
|
||||
| `Actionable` (interface) | Optional contract for type-hinting actions |
|
||||
| `Scheduled` (attribute) | Marks an Action for scheduled execution with frequency string |
|
||||
| `ScheduledAction` (model) | Eloquent model persisted to `scheduled_actions` table |
|
||||
| `ScheduledActionScanner` | Discovers `#[Scheduled]` attributes by scanning directories |
|
||||
| `ScheduleServiceProvider` | Reads enabled scheduled actions from DB and registers with Laravel scheduler |
|
||||
|
||||
## Public API
|
||||
|
||||
```php
|
||||
// Use the Action pattern
|
||||
class CreateOrder {
|
||||
use Action;
|
||||
public function handle(User $user, array $data): Order { ... }
|
||||
}
|
||||
CreateOrder::run($user, $data); // resolves from container
|
||||
|
||||
// Schedule an action
|
||||
#[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London')]
|
||||
class PublishDigest {
|
||||
use Action;
|
||||
public function handle(): void { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Frequency String Format
|
||||
|
||||
`method:arg1,arg2` maps directly to Laravel Schedule methods:
|
||||
- `everyMinute` / `hourly` / `daily` / `weekly` / `monthly`
|
||||
- `dailyAt:09:00` / `weeklyOn:1,09:00` / `cron:* * * * *`
|
||||
|
||||
## Integration
|
||||
|
||||
- Scanner skips `Tests/` directories and `*Test.php` files
|
||||
- ScheduleServiceProvider validates namespace (`App\`, `Core\`, `Mod\`) and frequency method against allowlists before executing
|
||||
- Actions are placed in `app/Mod/{Module}/Actions/`
|
||||
|
||||
## Conventions
|
||||
|
||||
- One action per file, named after what it does: `CreatePage`, `SendInvoice`
|
||||
- Dependencies injected via constructor
|
||||
- `handle()` is the single public method
|
||||
- Scheduling state is DB-driven (enable/disable without code changes)
|
||||
|
|
@ -99,7 +99,7 @@ class ScheduleServiceProvider extends ServiceProvider
|
|||
}
|
||||
|
||||
// Verify the class uses the Action trait
|
||||
if (! in_array(\Core\Actions\Action::class, class_uses_recursive($class), true)) {
|
||||
if (! in_array(Action::class, class_uses_recursive($class), true)) {
|
||||
logger()->warning("Scheduled action {$class} does not use the Action trait — skipping");
|
||||
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Actions;
|
|||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Represents a scheduled action persisted in the database.
|
||||
|
|
@ -24,10 +25,10 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property bool $without_overlapping
|
||||
* @property bool $run_in_background
|
||||
* @property bool $is_enabled
|
||||
* @property \Illuminate\Support\Carbon|null $last_run_at
|
||||
* @property \Illuminate\Support\Carbon|null $next_run_at
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
* @property \Illuminate\Support\Carbon $updated_at
|
||||
* @property Carbon|null $last_run_at
|
||||
* @property Carbon|null $next_run_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ScheduledAction extends Model
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Actions;
|
||||
|
||||
use Core\ModuleScanner;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use ReflectionClass;
|
||||
|
|
@ -24,7 +25,7 @@ use ReflectionClass;
|
|||
* It uses PHP's native reflection to read attributes — no file parsing.
|
||||
*
|
||||
* @see Scheduled The attribute this scanner discovers
|
||||
* @see \Core\ModuleScanner Similar pattern for Boot.php discovery
|
||||
* @see ModuleScanner Similar pattern for Boot.php discovery
|
||||
*/
|
||||
class ScheduledActionScanner
|
||||
{
|
||||
|
|
|
|||
48
src/Core/Activity/CLAUDE.md
Normal file
48
src/Core/Activity/CLAUDE.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Activity
|
||||
|
||||
Workspace-aware activity logging built on `spatie/laravel-activitylog`.
|
||||
|
||||
## What It Does
|
||||
|
||||
Wraps Spatie's activity log with automatic `workspace_id` tagging, a fluent query service, a Livewire feed component for the admin panel, and a prune command for retention management.
|
||||
|
||||
## Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Boot` | Registers console commands, Livewire component, and service binding via lifecycle events |
|
||||
| `Activity` (model) | Extends Spatie's model with `ActivityScopes` trait. Adds `workspace_id`, `old_values`, `new_values`, `changes`, `causer_name`, `subject_name` accessors |
|
||||
| `ActivityLogService` | Fluent query builder: `logFor($model)`, `logBy($user)`, `forWorkspace($ws)`, `ofType('updated')`, `search('term')`, `paginate()`, `statistics()`, `timeline()`, `prune()` |
|
||||
| `LogsActivity` (trait) | Drop-in trait for models. Auto-logs dirty attributes, auto-tags `workspace_id` from model or request context, generates human descriptions |
|
||||
| `ActivityScopes` (trait) | 20+ Eloquent scopes: `forWorkspace`, `forSubject`, `byCauser`, `ofType`, `betweenDates`, `today`, `lastDays`, `search`, `withChanges`, `withExistingSubject` |
|
||||
| `ActivityPruneCommand` | `php artisan activity:prune [--days=N] [--dry-run]` |
|
||||
| `ActivityFeed` (Livewire) | `<livewire:core.activity-feed :workspace-id="$id" />` with filters, search, pagination, detail modal |
|
||||
|
||||
## Public API
|
||||
|
||||
```php
|
||||
// Make a model log activity
|
||||
class Post extends Model {
|
||||
use LogsActivity;
|
||||
protected array $activityLogAttributes = ['title', 'status'];
|
||||
}
|
||||
|
||||
// Query activities
|
||||
$service = app(ActivityLogService::class);
|
||||
$service->logFor($post)->lastDays(7)->paginate();
|
||||
$service->forWorkspace($workspace)->ofType('deleted')->recent(10);
|
||||
$service->statistics($workspace); // => [total, by_event, by_subject, by_user]
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- Listens to `ConsoleBooting` and `AdminPanelBooting` lifecycle events
|
||||
- `LogsActivity` trait auto-detects workspace from model's `workspace_id` attribute, request `workspace_model` attribute, or authenticated user's `defaultHostWorkspace()`
|
||||
- Config: `core.activity.enabled`, `core.activity.retention_days` (default 90), `core.activity.log_name`
|
||||
- Override activity model in `config/activitylog.php`: `'activity_model' => Activity::class`
|
||||
|
||||
## Conventions
|
||||
|
||||
- `LogsActivity::withoutActivityLogging(fn() => ...)` to suppress logging during bulk operations
|
||||
- Models can implement `customizeActivity($activity, $event)` for custom property injection
|
||||
- Config properties on model: `$activityLogAttributes`, `$activityLogName`, `$activityLogEvents`, `$activityLogWorkspace`, `$activityLogOnlyDirty`
|
||||
17
src/Core/Activity/Concerns/CLAUDE.md
Normal file
17
src/Core/Activity/Concerns/CLAUDE.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Activity/Concerns/ — Activity Logging Trait
|
||||
|
||||
## Traits
|
||||
|
||||
| Trait | Purpose |
|
||||
|-------|---------|
|
||||
| `LogsActivity` | Drop-in trait for models that should log changes. Wraps `spatie/laravel-activitylog` with sensible defaults: auto workspace_id tagging, dirty-only logging, empty log suppression. |
|
||||
|
||||
## Configuration via Model Properties
|
||||
|
||||
- `$activityLogAttributes` — array of attributes to log (default: all dirty)
|
||||
- `$activityLogName` — custom log name
|
||||
- `$activityLogEvents` — events to log (default: created, updated, deleted)
|
||||
- `$activityLogWorkspace` — include workspace_id (default: true)
|
||||
- `$activityLogOnlyDirty` — only log changed attributes (default: true)
|
||||
|
||||
Static helpers: `activityLoggingEnabled()`, `withoutActivityLogging(callable)`.
|
||||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Activity\Concerns;
|
||||
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity as SpatieLogsActivity;
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ trait LogsActivity
|
|||
/**
|
||||
* Tap into the activity before it's saved to add workspace_id.
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||
public function tapActivity(Activity $activity, string $eventName): void
|
||||
{
|
||||
if ($this->shouldIncludeWorkspace()) {
|
||||
$workspaceId = $this->getActivityWorkspaceId();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Activity\Console;
|
|||
|
||||
use Core\Activity\Services\ActivityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
/**
|
||||
* Command to prune old activity logs.
|
||||
|
|
@ -48,7 +49,7 @@ class ActivityPruneCommand extends Command
|
|||
|
||||
if ($this->option('dry-run')) {
|
||||
// Count without deleting
|
||||
$activityModel = config('core.activity.activity_model', \Spatie\Activitylog\Models\Activity::class);
|
||||
$activityModel = config('core.activity.activity_model', Activity::class);
|
||||
$count = $activityModel::where('created_at', '<', $cutoffDate)->count();
|
||||
|
||||
$this->info("Would delete {$count} activity records.");
|
||||
|
|
|
|||
9
src/Core/Activity/Console/CLAUDE.md
Normal file
9
src/Core/Activity/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Activity/Console/ — Activity Log Commands
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Signature | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| `ActivityPruneCommand` | `activity:prune` | Prunes old activity logs. Options: `--days=N` (retention period), `--dry-run` (show count without deleting). Uses retention from config when days not specified. |
|
||||
|
||||
Part of the Activity subsystem's maintenance tooling. Should be scheduled in the application's console kernel for regular cleanup.
|
||||
|
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||
namespace Core\Activity\Models;
|
||||
|
||||
use Core\Activity\Scopes\ActivityScopes;
|
||||
use Illuminate\Support\Collection;
|
||||
use Spatie\Activitylog\Models\Activity as SpatieActivity;
|
||||
|
||||
/**
|
||||
|
|
@ -81,9 +82,9 @@ class Activity extends SpatieActivity
|
|||
/**
|
||||
* Get the changed attributes.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<string, array{old: mixed, new: mixed}>
|
||||
* @return Collection<string, array{old: mixed, new: mixed}>
|
||||
*/
|
||||
public function getChangesAttribute(): \Illuminate\Support\Collection
|
||||
public function getChangesAttribute(): Collection
|
||||
{
|
||||
$old = $this->old_values;
|
||||
$new = $this->new_values;
|
||||
|
|
|
|||
14
src/Core/Activity/Models/CLAUDE.md
Normal file
14
src/Core/Activity/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Activity/Models/ — Activity Log Model
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Extends | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `Activity` | `Spatie\Activitylog\Models\Activity` | Extended activity model with workspace-aware scopes via the `ActivityScopes` trait. Adds query scopes for filtering by workspace, subject, causer, event type, date range, and search. |
|
||||
|
||||
Configure as the activity model in `config/activitylog.php`:
|
||||
```php
|
||||
'activity_model' => \Core\Activity\Models\Activity::class,
|
||||
```
|
||||
|
||||
Requires `spatie/laravel-activitylog`.
|
||||
9
src/Core/Activity/Scopes/CLAUDE.md
Normal file
9
src/Core/Activity/Scopes/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Activity/Scopes/ — Activity Query Scopes
|
||||
|
||||
## Traits
|
||||
|
||||
| Trait | Purpose |
|
||||
|-------|---------|
|
||||
| `ActivityScopes` | Comprehensive query scopes for activity log filtering. Includes: `forWorkspace`, `forSubject`, `forSubjectType`, `byCauser`, `byCauserId`, `ofType`, `createdEvents`, `updatedEvents`, `deletedEvents`, `betweenDates`, `today`, `lastDays`, `lastHours`, `search`, `inLog`, `withChanges`, `withExistingSubject`, `withDeletedSubject`, `newest`, `oldest`. |
|
||||
|
||||
Used by `Core\Activity\Models\Activity`. Workspace scoping checks both `properties->workspace_id` and subject model's `workspace_id`. Requires `spatie/laravel-activitylog`.
|
||||
11
src/Core/Activity/Services/CLAUDE.md
Normal file
11
src/Core/Activity/Services/CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Activity/Services/ — Activity Log Service
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Purpose |
|
||||
|---------|---------|
|
||||
| `ActivityLogService` | Fluent interface for querying and managing activity logs. Methods: `logFor($model)`, `logBy($user)`, `forWorkspace($workspace)`, `recent()`, `search($term)`. Chainable query builder with workspace awareness. |
|
||||
|
||||
Provides the business logic layer over Spatie's activity log. Used by the `ActivityFeed` Livewire component and available for injection throughout the application.
|
||||
|
||||
Requires `spatie/laravel-activitylog`.
|
||||
9
src/Core/Activity/View/Blade/admin/CLAUDE.md
Normal file
9
src/Core/Activity/View/Blade/admin/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Activity/View/Blade/admin/ — Activity Feed Blade Template
|
||||
|
||||
## Templates
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `activity-feed.blade.php` | Admin panel activity log display — paginated list with filters (user, model type, event type, date range), activity detail modal with full diff view, optional polling for real-time updates. |
|
||||
|
||||
Rendered by the `ActivityFeed` Livewire component via the `core.activity::admin.*` view namespace.
|
||||
|
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||
namespace Core\Activity\View\Modal\Admin;
|
||||
|
||||
use Core\Activity\Services\ActivityLogService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
|
|
@ -362,7 +363,7 @@ class ActivityFeed extends Component
|
|||
};
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
return view('core.activity::admin.activity-feed');
|
||||
}
|
||||
|
|
|
|||
11
src/Core/Activity/View/Modal/Admin/CLAUDE.md
Normal file
11
src/Core/Activity/View/Modal/Admin/CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Activity/View/Modal/Admin/ — Activity Feed Livewire Component
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ActivityFeed` | Livewire component for displaying activity logs in the admin panel. Paginated list with URL-bound filters (causer, subject type, event type, date range, search). Supports workspace scoping and optional polling for real-time updates. |
|
||||
|
||||
Usage: `<livewire:core.activity-feed />` or `<livewire:core.activity-feed :workspace-id="$workspace->id" poll="10s" />`
|
||||
|
||||
Requires `spatie/laravel-activitylog`.
|
||||
|
|
@ -14,6 +14,7 @@ namespace Core;
|
|||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
|
||||
/**
|
||||
* Application bootstrap - configures Laravel with Core framework patterns.
|
||||
|
|
@ -36,16 +37,16 @@ class Boot
|
|||
*/
|
||||
public static array $providers = [
|
||||
// Lifecycle events - must load first to wire lazy listeners
|
||||
\Core\LifecycleEventProvider::class,
|
||||
LifecycleEventProvider::class,
|
||||
|
||||
// Websites - domain-scoped, must wire before frontages fire events
|
||||
\Core\Website\Boot::class,
|
||||
Website\Boot::class,
|
||||
|
||||
// Core frontages - fire lifecycle events
|
||||
\Core\Front\Boot::class,
|
||||
Front\Boot::class,
|
||||
|
||||
// Base modules (from core-php package)
|
||||
\Core\Mod\Boot::class,
|
||||
Mod\Boot::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +59,7 @@ class Boot
|
|||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Session middleware priority
|
||||
$middleware->priority([
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
StartSession::class,
|
||||
]);
|
||||
|
||||
$middleware->redirectGuestsTo('/login');
|
||||
|
|
|
|||
61
src/Core/Bouncer/CLAUDE.md
Normal file
61
src/Core/Bouncer/CLAUDE.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Bouncer
|
||||
|
||||
Early-exit security middleware + whitelist-based action authorisation gate.
|
||||
|
||||
## What It Does
|
||||
|
||||
Two subsystems in one:
|
||||
|
||||
1. **Bouncer** (top-level): IP blocklist + SEO redirects, runs before all other middleware
|
||||
2. **Gate** (subdirectory): Whitelist-based controller action authorisation with training mode
|
||||
|
||||
## Bouncer (IP Blocking + Redirects)
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Boot` | ServiceProvider registering `BlocklistService`, `RedirectService`, and migrations |
|
||||
| `BouncerMiddleware` | Early-exit middleware: sets trusted proxies, checks blocklist (O(1) via cached set), handles SEO redirects, then passes through |
|
||||
| `BlocklistService` | IP blocking with Redis-cached lookup. Statuses: `pending` (honeypot, needs review), `approved` (active block), `rejected` (reviewed, not blocked). Methods: `isBlocked()`, `block()`, `unblock()`, `syncFromHoneypot()`, `approve()`, `reject()`, `getPending()`, `getStats()` |
|
||||
| `RedirectService` | Cached SEO redirects from `seo_redirects` table. Supports exact match and wildcard (`path/*`). Methods: `match()`, `add()`, `remove()` |
|
||||
|
||||
### Hidden Ideas
|
||||
|
||||
- Blocked IPs get `418 I'm a teapot` with `X-Powered-By: Earl Grey`
|
||||
- Honeypot monitors paths from `robots.txt` disallow list; critical paths (`/admin`, `/.env`, `/wp-admin`) trigger auto-block
|
||||
- Rate-limited honeypot logging prevents DoS via log flooding
|
||||
- `TRUSTED_PROXIES` env var: comma-separated IPs or `*` (trust all)
|
||||
|
||||
## Gate (Action Whitelist)
|
||||
|
||||
Philosophy: **"If it wasn't trained, it doesn't exist."**
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Gate\Boot` | ServiceProvider registering middleware, migrations, route macros, and training routes |
|
||||
| `ActionGateService` | Resolves action name from route (3-level priority), checks against `ActionPermission` table, logs to `ActionRequest`. Methods: `check()`, `allow()`, `deny()`, `resolveAction()` |
|
||||
| `ActionGateMiddleware` | Enforces gate: allowed = pass, denied = 403, training = approval prompt (JSON for API, redirect for web) |
|
||||
| `Action` (attribute) | `#[Action('product.create', scope: 'product')]` on controller methods |
|
||||
| `ActionPermission` (model) | Whitelist record: action + guard + role + scope. Methods: `isAllowed()`, `train()`, `revoke()`, `allowedFor()` |
|
||||
| `ActionRequest` (model) | Audit log of all permission checks. Methods: `log()`, `pending()`, `deniedActionsSummary()`, `prune()` |
|
||||
| `RouteActionMacro` | Adds `->action('name')`, `->bypassGate()`, `->requiresTraining()` to Route |
|
||||
|
||||
### Action Resolution Priority
|
||||
|
||||
1. Route action: `Route::post(...)->action('product.create')`
|
||||
2. Controller attribute: `#[Action('product.create')]`
|
||||
3. Auto-resolved: `ProductController@store` becomes `product.store`
|
||||
|
||||
### Training Mode
|
||||
|
||||
When `core.bouncer.training_mode = true`, unknown actions prompt for approval instead of blocking. Training routes at `/_bouncer/approve` and `/_bouncer/pending`.
|
||||
|
||||
## Integration
|
||||
|
||||
- BouncerMiddleware runs FIRST in the stack (replaces Laravel TrustProxies)
|
||||
- ActionGateMiddleware appends to `web`, `admin`, `api`, `client` groups
|
||||
- Config: `core.bouncer.enabled`, `core.bouncer.training_mode`, `core.bouncer.guarded_middleware`
|
||||
- DB tables: `blocked_ips`, `seo_redirects`, `honeypot_hits`, `core_action_permissions`, `core_action_requests`
|
||||
7
src/Core/Bouncer/Database/Seeders/CLAUDE.md
Normal file
7
src/Core/Bouncer/Database/Seeders/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Bouncer/Database/Seeders/ — Bouncer Seeders
|
||||
|
||||
## Seeders
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `WebsiteRedirectSeeder.php` | Seeds 301 redirects for renamed website URLs. Uses the `RedirectService` to register old-to-new path mappings (e.g., `/services/biohost` -> `/services/bio`). Added during URL simplification (2026-01-16). |
|
||||
14
src/Core/Bouncer/Gate/Attributes/CLAUDE.md
Normal file
14
src/Core/Bouncer/Gate/Attributes/CLAUDE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Bouncer/Gate/Attributes/ — Action Gate PHP Attributes
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Target | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| `#[Action(name, scope?)]` | Method, Class | Declares an explicit action name for permission checking, overriding auto-resolution from controller/method names. Optional `scope` for resource-specific permissions. |
|
||||
|
||||
Without this attribute, action names are auto-resolved: `ProductController@store` becomes `product.store`.
|
||||
|
||||
```php
|
||||
#[Action('product.create')]
|
||||
public function store(Request $request) { ... }
|
||||
```
|
||||
18
src/Core/Bouncer/Gate/CLAUDE.md
Normal file
18
src/Core/Bouncer/Gate/CLAUDE.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Bouncer/Gate/ — Action Gate Authorisation
|
||||
|
||||
Whitelist-based request authorisation system. Philosophy: "If it wasn't trained, it doesn't exist."
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Boot.php` | ServiceProvider — registers middleware, configures action gate. |
|
||||
| `ActionGateMiddleware.php` | Intercepts requests, checks if the target action is permitted. Production mode blocks unknown actions (403). Training mode prompts for approval. |
|
||||
| `ActionGateService.php` | Core service — resolves action names from routes/controllers, checks `ActionPermission` records. Supports `#[Action]` attribute, auto-resolution from controller names, and training mode. |
|
||||
| `RouteActionMacro.php` | Adds `->action('name')` and `->bypassGate()` macros to Laravel routes for fluent action naming. |
|
||||
|
||||
## Integration Flow
|
||||
|
||||
```
|
||||
Request -> ActionGateMiddleware -> ActionGateService::check() -> ActionPermission (allowed/denied) -> Controller
|
||||
```
|
||||
7
src/Core/Bouncer/Gate/Migrations/CLAUDE.md
Normal file
7
src/Core/Bouncer/Gate/Migrations/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Bouncer/Gate/Migrations/ — Action Gate Schema
|
||||
|
||||
## Migrations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `0001_01_01_000002_create_action_permission_tables.php` | Creates `core_action_permissions` (whitelisted actions) and `core_action_requests` (audit log) tables. |
|
||||
|
|
@ -11,6 +11,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Bouncer\Gate\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -29,9 +31,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property string $source How this was created ('trained', 'seeded', 'manual')
|
||||
* @property string|null $trained_route The route used during training
|
||||
* @property int|null $trained_by User ID who trained this action
|
||||
* @property \Carbon\Carbon|null $trained_at When training occurred
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Carbon|null $trained_at When training occurred
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ActionPermission extends Model
|
||||
{
|
||||
|
|
@ -174,9 +176,9 @@ class ActionPermission extends Model
|
|||
/**
|
||||
* Get all actions for a guard.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forGuard(string $guard): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forGuard(string $guard): Collection
|
||||
{
|
||||
return static::where('guard', $guard)->get();
|
||||
}
|
||||
|
|
@ -184,9 +186,9 @@ class ActionPermission extends Model
|
|||
/**
|
||||
* Get all allowed actions for a guard/role combination.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function allowedFor(string $guard, ?string $role = null): \Illuminate\Database\Eloquent\Collection
|
||||
public static function allowedFor(string $guard, ?string $role = null): Collection
|
||||
{
|
||||
$query = static::where('guard', $guard)
|
||||
->where('allowed', true);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Bouncer\Gate\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -30,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property string|null $ip_address Client IP
|
||||
* @property string $status Result: 'allowed', 'denied', 'pending'
|
||||
* @property bool $was_trained Whether this request triggered training
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ActionRequest extends Model
|
||||
{
|
||||
|
|
@ -103,9 +105,9 @@ class ActionRequest extends Model
|
|||
/**
|
||||
* Get pending requests (for training review).
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function pending(): \Illuminate\Database\Eloquent\Collection
|
||||
public static function pending(): Collection
|
||||
{
|
||||
return static::where('status', self::STATUS_PENDING)
|
||||
->orderBy('created_at', 'desc')
|
||||
|
|
@ -115,9 +117,9 @@ class ActionRequest extends Model
|
|||
/**
|
||||
* Get denied requests for an action.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function deniedFor(string $action): \Illuminate\Database\Eloquent\Collection
|
||||
public static function deniedFor(string $action): Collection
|
||||
{
|
||||
return static::where('action', $action)
|
||||
->where('status', self::STATUS_DENIED)
|
||||
|
|
@ -128,9 +130,9 @@ class ActionRequest extends Model
|
|||
/**
|
||||
* Get requests by user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forUser(int $userId): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forUser(int $userId): Collection
|
||||
{
|
||||
return static::where('user_id', $userId)
|
||||
->orderBy('created_at', 'desc')
|
||||
|
|
|
|||
8
src/Core/Bouncer/Gate/Models/CLAUDE.md
Normal file
8
src/Core/Bouncer/Gate/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Bouncer/Gate/Models/ — Action Gate Models
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Table | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `ActionPermission` | `core_action_permissions` | Whitelisted action record. Stores action identifier, scope, guard, role, allowed flag, and training metadata (who trained it, when, from which route). Source: `trained`, `seeded`, or `manual`. |
|
||||
| `ActionRequest` | `core_action_requests` | Audit log entry for all action permission checks. Records HTTP method, route, action, guard, user, IP, status (allowed/denied/pending), and whether training was triggered. |
|
||||
|
|
@ -13,6 +13,7 @@ namespace Core\Bouncer\Gate\Tests\Feature;
|
|||
|
||||
use Core\Bouncer\Gate\ActionGateService;
|
||||
use Core\Bouncer\Gate\Attributes\Action;
|
||||
use Core\Bouncer\Gate\Boot;
|
||||
use Core\Bouncer\Gate\Models\ActionPermission;
|
||||
use Core\Bouncer\Gate\Models\ActionRequest;
|
||||
use Core\Bouncer\Gate\RouteActionMacro;
|
||||
|
|
@ -40,7 +41,7 @@ class ActionGateTest extends TestCase
|
|||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [
|
||||
\Core\Bouncer\Gate\Boot::class,
|
||||
Boot::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
7
src/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md
Normal file
7
src/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Bouncer/Gate/Tests/Feature/ — Action Gate Feature Tests
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ActionGateTest.php` | Integration tests for the full action gate flow — middleware interception, permission enforcement, training mode responses, route macro behaviour. |
|
||||
7
src/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md
Normal file
7
src/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Bouncer/Gate/Tests/Unit/ — Action Gate Unit Tests
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ActionGateServiceTest.php` | Unit tests for the `ActionGateService`. Tests action name resolution from routes and controllers, permission checking, training mode behaviour, and `#[Action]` attribute support. |
|
||||
9
src/Core/Bouncer/Migrations/CLAUDE.md
Normal file
9
src/Core/Bouncer/Migrations/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Bouncer/Migrations/ — Bouncer Schema Migrations
|
||||
|
||||
## Migrations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `0001_01_01_000001_create_bouncer_tables.php` | Creates core bouncer tables for IP/domain blocklisting, redirect rules, and rate limiting configuration. |
|
||||
|
||||
Uses early timestamps to run before application migrations.
|
||||
7
src/Core/Bouncer/Tests/Unit/CLAUDE.md
Normal file
7
src/Core/Bouncer/Tests/Unit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Bouncer/Tests/Unit/ — Bouncer Unit Tests
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `BlocklistServiceTest.php` | Unit tests for the IP/domain blocklist service. Tests blocking, allowing, and checking IPs and domains against the blocklist. |
|
||||
83
src/Core/CLAUDE.md
Normal file
83
src/Core/CLAUDE.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Core Orchestration
|
||||
|
||||
Root-level files in `src/Core/` that wire the entire framework together. These are the bootstrap, module discovery, lazy loading, and pro-feature detection systems.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Init.php` | True entry point. `Core\Init::handle()` replaces Laravel's `bootstrap/app.php`. Runs WAF input filtering via `Input::capture()`, then delegates to `Boot::app()`. Prefers `App\Boot` if it exists. |
|
||||
| `Boot.php` | Configures Laravel `Application` with providers, middleware, and exceptions. Provider load order is critical: `LifecycleEventProvider` -> `Website\Boot` -> `Front\Boot` -> `Mod\Boot`. |
|
||||
| `LifecycleEventProvider.php` | The orchestrator. Registers `ModuleScanner` and `ModuleRegistry` as singletons, scans configured paths, wires lazy listeners. Static `fire*()` methods are called by frontage modules to dispatch lifecycle events and process collected requests (views, livewire, routes, middleware). |
|
||||
| `ModuleScanner.php` | Discovers `Boot.php` files in subdirectories of given paths. Reads static `$listens` arrays via reflection without instantiating modules. Maps paths to namespaces (`/Core` -> `Core\`, `/Mod` -> `Mod\`, `/Website` -> `Website\`, `/Plug` -> `Plug\`). |
|
||||
| `ModuleRegistry.php` | Coordinates scanner output into Laravel's event system. Sorts listeners by priority (highest first), creates `LazyModuleListener` instances, supports late-registration via `addPaths()`. |
|
||||
| `LazyModuleListener.php` | The lazy-loading wrapper. Instantiates module on first event fire (cached thereafter). ServiceProviders use `resolveProvider()`, plain classes use `make()`. Records audit logs and profiling data. |
|
||||
| `Pro.php` | Detects Flux Pro and FontAwesome Pro installations. Auto-enables pro features, falls back gracefully to free equivalents. Throws helpful dev-mode exceptions. |
|
||||
| `config.php` | Framework configuration: branding, domains, CDN, organisation, social links, contact, FontAwesome, pro fallback behaviour, icon defaults, debug settings, seeder auto-discovery. |
|
||||
|
||||
## Bootstrap Sequence
|
||||
|
||||
```
|
||||
public/index.php
|
||||
-> Core\Init::handle()
|
||||
-> Input::capture() # WAF layer sanitises $_GET/$_POST
|
||||
-> Boot::app() # Build Laravel Application
|
||||
-> LifecycleEventProvider # register(): scan + wire lazy listeners
|
||||
-> Website\Boot # register(): domain resolution
|
||||
-> Front\Boot # boot(): fires lifecycle events
|
||||
-> Mod\Boot # aggregates feature modules
|
||||
```
|
||||
|
||||
## Module Declaration Pattern
|
||||
|
||||
Modules declare interest in events via static `$listens`:
|
||||
|
||||
```php
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
WebRoutesRegistering::class => 'onWebRoutes',
|
||||
AdminPanelBooting::class => ['onAdmin', 10], // priority 10
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Modules are never instantiated until their event fires.
|
||||
|
||||
## Lifecycle Events (fire* methods)
|
||||
|
||||
| Method | Event | Middleware | Processes |
|
||||
|--------|-------|-----------|-----------|
|
||||
| `fireWebRoutes()` | `WebRoutesRegistering` | `web` | views, livewire, routes |
|
||||
| `fireAdminBooting()` | `AdminPanelBooting` | `admin` | views, translations, livewire, routes |
|
||||
| `fireClientRoutes()` | `ClientRoutesRegistering` | `client` | views, livewire, routes |
|
||||
| `fireApiRoutes()` | `ApiRoutesRegistering` | `api` | routes |
|
||||
| `fireMcpRoutes()` | `McpRoutesRegistering` | `mcp` | routes |
|
||||
| `fireMcpTools()` | `McpToolsRegistering` | -- | returns handler class names |
|
||||
| `fireConsoleBooting()` | `ConsoleBooting` | -- | artisan commands |
|
||||
| `fireQueueWorkerBooting()` | `QueueWorkerBooting` | -- | queue-specific init |
|
||||
|
||||
All route-registering fire methods call `refreshRoutes()` afterward to deduplicate names and refresh lookups.
|
||||
|
||||
## Default Scan Paths
|
||||
|
||||
- `app_path('Core')` -- application-level core modules
|
||||
- `app_path('Mod')` -- feature modules
|
||||
- `app_path('Website')` -- domain-scoped website modules
|
||||
- `src/Core` -- framework's own modules
|
||||
- `src/Mod` -- framework's own feature modules
|
||||
|
||||
Configurable via `config('core.module_paths')`.
|
||||
|
||||
## Priority System
|
||||
|
||||
- Default: `0`
|
||||
- Higher values run first: `['onAdmin', 100]` runs before `['onAdmin', 0]`
|
||||
- Negative values run last: `['onCleanup', -10]`
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
- `Init::boot()` returns `App\Boot` if it exists, allowing apps to customise providers
|
||||
- `Boot::basePath()` auto-detects monorepo vs vendor structure
|
||||
- `LifecycleEventProvider` processes middleware aliases, view namespaces, and Livewire components collected during event dispatch
|
||||
- Route deduplication prevents `route:cache` failures when the same route file serves multiple domains
|
||||
|
|
@ -11,6 +11,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Cdn;
|
||||
|
||||
use App\Facades\Cdn;
|
||||
use App\Http\Middleware\RewriteOffloadedUrls;
|
||||
use App\Jobs\PushAssetToCdn;
|
||||
use App\Traits\HasCdnUrls;
|
||||
use Core\Cdn\Console\CdnPurge;
|
||||
use Core\Cdn\Console\OffloadMigrateCommand;
|
||||
use Core\Cdn\Console\PushAssetsToCdn;
|
||||
|
|
@ -21,6 +25,9 @@ use Core\Cdn\Services\BunnyStorageService;
|
|||
use Core\Cdn\Services\FluxCdnService;
|
||||
use Core\Cdn\Services\StorageOffload;
|
||||
use Core\Cdn\Services\StorageUrlResolver;
|
||||
use Core\Crypt\LthnHash;
|
||||
use Core\Plug\Cdn\CdnManager;
|
||||
use Core\Plug\Storage\StorageManager;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
|
|
@ -45,11 +52,11 @@ class Boot extends ServiceProvider
|
|||
$this->mergeConfigFrom(__DIR__.'/offload.php', 'offload');
|
||||
|
||||
// Register Plug managers as singletons (when available)
|
||||
if (class_exists(\Core\Plug\Cdn\CdnManager::class)) {
|
||||
$this->app->singleton(\Core\Plug\Cdn\CdnManager::class);
|
||||
if (class_exists(CdnManager::class)) {
|
||||
$this->app->singleton(CdnManager::class);
|
||||
}
|
||||
if (class_exists(\Core\Plug\Storage\StorageManager::class)) {
|
||||
$this->app->singleton(\Core\Plug\Storage\StorageManager::class);
|
||||
if (class_exists(StorageManager::class)) {
|
||||
$this->app->singleton(StorageManager::class);
|
||||
}
|
||||
|
||||
// Register legacy services as singletons (for backward compatibility)
|
||||
|
|
@ -115,32 +122,32 @@ class Boot extends ServiceProvider
|
|||
|
||||
// Crypt
|
||||
if (! class_exists(\App\Services\Crypt\LthnHash::class)) {
|
||||
class_alias(\Core\Crypt\LthnHash::class, \App\Services\Crypt\LthnHash::class);
|
||||
class_alias(LthnHash::class, \App\Services\Crypt\LthnHash::class);
|
||||
}
|
||||
|
||||
// Models
|
||||
if (! class_exists(\App\Models\StorageOffload::class)) {
|
||||
class_alias(\Core\Cdn\Models\StorageOffload::class, \App\Models\StorageOffload::class);
|
||||
class_alias(Models\StorageOffload::class, \App\Models\StorageOffload::class);
|
||||
}
|
||||
|
||||
// Facades
|
||||
if (! class_exists(\App\Facades\Cdn::class)) {
|
||||
class_alias(\Core\Cdn\Facades\Cdn::class, \App\Facades\Cdn::class);
|
||||
if (! class_exists(Cdn::class)) {
|
||||
class_alias(Facades\Cdn::class, Cdn::class);
|
||||
}
|
||||
|
||||
// Traits
|
||||
if (! trait_exists(\App\Traits\HasCdnUrls::class)) {
|
||||
class_alias(\Core\Cdn\Traits\HasCdnUrls::class, \App\Traits\HasCdnUrls::class);
|
||||
if (! trait_exists(HasCdnUrls::class)) {
|
||||
class_alias(Traits\HasCdnUrls::class, HasCdnUrls::class);
|
||||
}
|
||||
|
||||
// Middleware
|
||||
if (! class_exists(\App\Http\Middleware\RewriteOffloadedUrls::class)) {
|
||||
class_alias(\Core\Cdn\Middleware\RewriteOffloadedUrls::class, \App\Http\Middleware\RewriteOffloadedUrls::class);
|
||||
if (! class_exists(RewriteOffloadedUrls::class)) {
|
||||
class_alias(Middleware\RewriteOffloadedUrls::class, RewriteOffloadedUrls::class);
|
||||
}
|
||||
|
||||
// Jobs
|
||||
if (! class_exists(\App\Jobs\PushAssetToCdn::class)) {
|
||||
class_alias(\Core\Cdn\Jobs\PushAssetToCdn::class, \App\Jobs\PushAssetToCdn::class);
|
||||
if (! class_exists(PushAssetToCdn::class)) {
|
||||
class_alias(Jobs\PushAssetToCdn::class, PushAssetToCdn::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/Core/Cdn/CLAUDE.md
Normal file
57
src/Core/Cdn/CLAUDE.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Cdn
|
||||
|
||||
BunnyCDN integration with vBucket workspace isolation and storage offloading.
|
||||
|
||||
## What It Does
|
||||
|
||||
Unified CDN and object storage layer providing:
|
||||
- BunnyCDN pull zone operations (purge, stats)
|
||||
- BunnyCDN storage zone operations (upload, download, list, delete)
|
||||
- Context-aware URL building (CDN, origin, private, signed)
|
||||
- vBucket-scoped paths using `LthnHash` for tenant isolation
|
||||
- Asset pipeline for processing and offloading
|
||||
- Flux Pro CDN delivery
|
||||
- Storage offload migration from local to CDN
|
||||
|
||||
## Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Boot` | ServiceProvider registering all services as singletons + backward-compat aliases to `App\` namespaces |
|
||||
| `BunnyCdnService` | Pull zone API: `purgeUrl()`, `purgeUrls()`, `purgeAll()`, `purgeByTag()`, `purgeWorkspace()`, `getStats()`, `getBandwidth()`, `listStorageFiles()`, `uploadFile()`, `deleteFile()`. Sanitises error messages to redact API keys |
|
||||
| `BunnyStorageService` | Direct storage zone operations (separate from pull zone API) |
|
||||
| `CdnUrlBuilder` | URL construction: `cdn()`, `origin()`, `private()`, `apex()`, `signed()`, `vBucket()`, `vBucketId()`, `vBucketPath()`, `asset()`, `withVersion()`, `urls()`, `allUrls()` |
|
||||
| `StorageUrlResolver` | Context-aware URL resolution |
|
||||
| `FluxCdnService` | Flux Pro component CDN delivery |
|
||||
| `AssetPipeline` | Asset processing pipeline |
|
||||
| `StorageOffload` (service) | Migrates files from local storage to CDN |
|
||||
| `StorageOffload` (model) | Tracks offloaded files in DB |
|
||||
| `Cdn` (facade) | `Cdn::purge(...)` etc. |
|
||||
| `HasCdnUrls` (trait) | Adds CDN URL methods to Eloquent models |
|
||||
|
||||
## Console Commands
|
||||
|
||||
- `cdn:purge` -- Purge CDN cache
|
||||
- `cdn:push-assets` -- Push assets to CDN storage
|
||||
- `cdn:push-flux` -- Push Flux Pro assets to CDN
|
||||
- `cdn:offload-migrate` -- Migrate local files to CDN storage
|
||||
|
||||
## Middleware
|
||||
|
||||
- `RewriteOffloadedUrls` -- Rewrites storage URLs in responses to CDN URLs
|
||||
- `LocalCdnMiddleware` -- Serves CDN assets locally in development
|
||||
|
||||
## vBucket Pattern
|
||||
|
||||
Workspace-isolated CDN paths using `LthnHash::vBucketId()`:
|
||||
```
|
||||
cdn.example.com/{vBucketId}/path/to/asset.js
|
||||
```
|
||||
The vBucketId is a deterministic SHA-256 hash of the domain name, ensuring each workspace's assets are namespaced.
|
||||
|
||||
## Integration
|
||||
|
||||
- Reads credentials from `ConfigService` (DB-backed config), not just `.env`
|
||||
- Signed URLs use HMAC-SHA256 with BunnyCDN token authentication
|
||||
- Config files: `config.php` (CDN settings), `offload.php` (storage offload settings)
|
||||
- Backward-compat aliases registered for all `App\Services\*` and `App\Models\*` namespaces
|
||||
10
src/Core/Cdn/Console/CLAUDE.md
Normal file
10
src/Core/Cdn/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Cdn/Console/ — CDN Artisan Commands
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Signature | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| `CdnPurge` | `cdn:purge` | Purge CDN cache — by URL, tag, workspace, or global. |
|
||||
| `OffloadMigrateCommand` | `cdn:offload-migrate` | Migrate local files to remote storage, creating offload records. |
|
||||
| `PushAssetsToCdn` | `cdn:push` | Push local assets to CDN storage zone. |
|
||||
| `PushFluxToCdn` | `cdn:push-flux` | Push Flux UI framework assets to CDN. |
|
||||
|
|
@ -11,6 +11,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Cdn\Console;
|
||||
|
||||
use Core\Plug\Cdn\Bunny\Purge;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CdnPurge extends Command
|
||||
|
|
@ -43,8 +45,8 @@ class CdnPurge extends Command
|
|||
{
|
||||
parent::__construct();
|
||||
|
||||
if (class_exists(\Core\Plug\Cdn\Bunny\Purge::class)) {
|
||||
$this->purger = new \Core\Plug\Cdn\Bunny\Purge;
|
||||
if (class_exists(Purge::class)) {
|
||||
$this->purger = new Purge;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,8 +98,8 @@ class CdnPurge extends Command
|
|||
// Purge by workspace
|
||||
if (empty($workspaceArg)) {
|
||||
$workspaceOptions = ['all', 'Select specific URLs'];
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
$workspaceOptions = array_merge($workspaceOptions, \Core\Tenant\Models\Workspace::pluck('slug')->toArray());
|
||||
if (class_exists(Workspace::class)) {
|
||||
$workspaceOptions = array_merge($workspaceOptions, Workspace::pluck('slug')->toArray());
|
||||
}
|
||||
$workspaceArg = $this->choice(
|
||||
'What would you like to purge?',
|
||||
|
|
@ -218,13 +220,13 @@ class CdnPurge extends Command
|
|||
|
||||
protected function purgeAllWorkspaces(bool $dryRun): int
|
||||
{
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->error('Workspace purge requires Tenant module to be installed.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspaces = \Core\Tenant\Models\Workspace::all();
|
||||
$workspaces = Workspace::all();
|
||||
|
||||
if ($workspaces->isEmpty()) {
|
||||
$this->error('No workspaces found');
|
||||
|
|
@ -276,19 +278,19 @@ class CdnPurge extends Command
|
|||
|
||||
protected function purgeWorkspace(string $slug, bool $dryRun): int
|
||||
{
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->error('Workspace purge requires Tenant module to be installed.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $slug)->first();
|
||||
$workspace = Workspace::where('slug', $slug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->error("Workspace not found: {$slug}");
|
||||
$this->newLine();
|
||||
$this->info('Available workspaces:');
|
||||
\Core\Tenant\Models\Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
|
||||
Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Cdn\Console;
|
|||
|
||||
use Core\Cdn\Services\FluxCdnService;
|
||||
use Core\Cdn\Services\StorageUrlResolver;
|
||||
use Core\Plug\Storage\Bunny\VBucket;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ class PushAssetsToCdn extends Command
|
|||
|
||||
public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int
|
||||
{
|
||||
if (! class_exists(\Core\Plug\Storage\Bunny\VBucket::class)) {
|
||||
if (! class_exists(VBucket::class)) {
|
||||
$this->error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.');
|
||||
|
||||
return self::FAILURE;
|
||||
|
|
@ -54,7 +55,7 @@ class PushAssetsToCdn extends Command
|
|||
|
||||
// Create vBucket for workspace isolation
|
||||
$domain = $this->option('domain');
|
||||
$this->vbucket = \Core\Plug\Storage\Bunny\VBucket::public($domain);
|
||||
$this->vbucket = VBucket::public($domain);
|
||||
|
||||
$pushFlux = $this->option('flux');
|
||||
$pushFontawesome = $this->option('fontawesome');
|
||||
|
|
|
|||
9
src/Core/Cdn/Facades/CLAUDE.md
Normal file
9
src/Core/Cdn/Facades/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Cdn/Facades/ — CDN Facade
|
||||
|
||||
## Facades
|
||||
|
||||
| Facade | Resolves To | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `Cdn` | `StorageUrlResolver` | Static proxy for CDN operations — `cdn()`, `origin()`, `private()`, `signedUrl()`, `asset()`, `pushToCdn()`, `deleteFromCdn()`, `purge()`, `storePublic()`, `storePrivate()`, `vBucketCdn()`, and more. |
|
||||
|
||||
Usage: `Cdn::cdn('images/logo.png')` returns the CDN URL for the asset.
|
||||
|
|
@ -42,7 +42,7 @@ use Illuminate\Support\Facades\Facade;
|
|||
* @method static string vBucketPath(string $domain, string $path)
|
||||
* @method static array vBucketUrls(string $domain, string $path)
|
||||
*
|
||||
* @see \Core\Cdn\Services\StorageUrlResolver
|
||||
* @see StorageUrlResolver
|
||||
*/
|
||||
class Cdn extends Facade
|
||||
{
|
||||
|
|
|
|||
7
src/Core/Cdn/Jobs/CLAUDE.md
Normal file
7
src/Core/Cdn/Jobs/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Cdn/Jobs/ — CDN Background Jobs
|
||||
|
||||
## Jobs
|
||||
|
||||
| Job | Purpose |
|
||||
|-----|---------|
|
||||
| `PushAssetToCdn` | Queued job to push a local asset file to the CDN (BunnyCDN). Handles upload, verification, and StorageOffload record creation. |
|
||||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Cdn\Jobs;
|
||||
|
||||
use Core\Plug\Storage\StorageManager;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
|
@ -60,7 +61,7 @@ class PushAssetToCdn implements ShouldQueue
|
|||
*/
|
||||
public function handle(?object $storage = null): void
|
||||
{
|
||||
if (! class_exists(\Core\Plug\Storage\StorageManager::class)) {
|
||||
if (! class_exists(StorageManager::class)) {
|
||||
Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed');
|
||||
|
||||
return;
|
||||
|
|
@ -68,7 +69,7 @@ class PushAssetToCdn implements ShouldQueue
|
|||
|
||||
// Resolve from container if not injected
|
||||
if ($storage === null) {
|
||||
$storage = app(\Core\Plug\Storage\StorageManager::class);
|
||||
$storage = app(StorageManager::class);
|
||||
}
|
||||
|
||||
if (! config('cdn.bunny.push_enabled', false)) {
|
||||
|
|
|
|||
8
src/Core/Cdn/Middleware/CLAUDE.md
Normal file
8
src/Core/Cdn/Middleware/CLAUDE.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Cdn/Middleware/ — CDN HTTP Middleware
|
||||
|
||||
## Middleware
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `LocalCdnMiddleware` | Adds aggressive caching headers and compression for requests on the `cdn.*` subdomain. Provides CDN-like behaviour without external services. |
|
||||
| `RewriteOffloadedUrls` | Processes JSON responses and replaces local storage paths with remote equivalents when files have been offloaded to external storage. |
|
||||
7
src/Core/Cdn/Models/CLAUDE.md
Normal file
7
src/Core/Cdn/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Cdn/Models/ — CDN Storage Models
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `StorageOffload` | Tracks files offloaded to remote storage. Records local path, remote path, disk, SHA-256 hash, file size, MIME type, category, metadata, and offload timestamp. Used by the URL rewriting middleware. |
|
||||
|
|
@ -13,6 +13,7 @@ namespace Core\Cdn\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Tracks files that have been offloaded to remote storage.
|
||||
|
|
@ -26,9 +27,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property string|null $mime_type MIME type
|
||||
* @property string|null $category Category for path prefixing
|
||||
* @property array|null $metadata Additional metadata
|
||||
* @property \Illuminate\Support\Carbon|null $offloaded_at When file was offloaded
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property Carbon|null $offloaded_at When file was offloaded
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class StorageOffload extends Model
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Cdn\Services;
|
|||
|
||||
use Core\Cdn\Jobs\PushAssetToCdn;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
|
|
@ -339,7 +340,7 @@ class AssetPipeline
|
|||
PushAssetToCdn::dispatch($disk, $path, $zone);
|
||||
} elseif ($this->storage !== null) {
|
||||
// Synchronous push if no queue configured (requires StorageManager from Plug module)
|
||||
$diskInstance = \Illuminate\Support\Facades\Storage::disk($disk);
|
||||
$diskInstance = Storage::disk($disk);
|
||||
if ($diskInstance->exists($path)) {
|
||||
$contents = $diskInstance->get($path);
|
||||
$this->storage->zone($zone)->upload()->contents($path, $contents);
|
||||
|
|
|
|||
13
src/Core/Cdn/Services/CLAUDE.md
Normal file
13
src/Core/Cdn/Services/CLAUDE.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Cdn/Services/ — CDN Service Layer
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Purpose |
|
||||
|---------|---------|
|
||||
| `BunnyCdnService` | BunnyCDN pull zone API — cache purging (URL, tag, workspace, global), statistics retrieval, pull zone management. Uses config from `ConfigService`. |
|
||||
| `BunnyStorageService` | BunnyCDN storage zone API — file upload, download, delete, list. Supports public and private storage zones. |
|
||||
| `StorageOffload` (service) | Manages file offloading to remote storage — upload, track, verify. Creates `StorageOffload` model records. |
|
||||
| `StorageUrlResolver` | URL builder for all asset contexts — CDN, origin, private, signed, apex. Supports virtual buckets (vBucket) per domain. Backs the `Cdn` facade. |
|
||||
| `CdnUrlBuilder` | Low-level URL construction for CDN paths with cache-busting and domain resolution. |
|
||||
| `AssetPipeline` | Orchestrates asset processing — push to CDN, cache headers, versioning. |
|
||||
| `FluxCdnService` | Pushes Flux UI assets to CDN for faster component loading. |
|
||||
|
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||
namespace Core\Cdn\Services;
|
||||
|
||||
use Core\Helpers\Cdn;
|
||||
use Flux\AssetManager;
|
||||
use Flux\Flux;
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +84,7 @@ class FluxCdnService
|
|||
|
||||
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
|
||||
if (! $this->shouldUseCdn()) {
|
||||
return \Flux\AssetManager::editorScripts();
|
||||
return AssetManager::editorScripts();
|
||||
}
|
||||
|
||||
// In production, use CDN URL (no vBucket - shared platform asset)
|
||||
|
|
@ -109,7 +110,7 @@ class FluxCdnService
|
|||
|
||||
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
|
||||
if (! $this->shouldUseCdn()) {
|
||||
return \Flux\AssetManager::editorStyles();
|
||||
return AssetManager::editorStyles();
|
||||
}
|
||||
|
||||
// In production, use CDN URL (no vBucket - shared platform asset)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Cdn\Services;
|
|||
|
||||
use Core\Cdn\Models\StorageOffload as OffloadModel;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -305,7 +306,7 @@ class StorageOffload
|
|||
/**
|
||||
* Get all offloaded files for a category.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<OffloadModel>
|
||||
* @return Collection<OffloadModel>
|
||||
*/
|
||||
public function getByCategory(string $category)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ declare(strict_types=1);
|
|||
namespace Core\Cdn\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Cdn\Jobs\PushAssetToCdn;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -372,7 +374,7 @@ class StorageUrlResolver
|
|||
/**
|
||||
* Get the public storage disk.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Filesystem\Filesystem
|
||||
* @return Filesystem
|
||||
*/
|
||||
public function publicDisk()
|
||||
{
|
||||
|
|
@ -382,7 +384,7 @@ class StorageUrlResolver
|
|||
/**
|
||||
* Get the private storage disk.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Filesystem\Filesystem
|
||||
* @return Filesystem
|
||||
*/
|
||||
public function privateDisk()
|
||||
{
|
||||
|
|
@ -403,7 +405,7 @@ class StorageUrlResolver
|
|||
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
|
||||
// Queue the push if configured, otherwise push synchronously
|
||||
if ($queue = config('cdn.pipeline.queue')) {
|
||||
dispatch(new \Core\Cdn\Jobs\PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
|
||||
dispatch(new PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
|
||||
} else {
|
||||
$this->pushToCdn('hetzner-public', $path, 'public');
|
||||
}
|
||||
|
|
@ -425,7 +427,7 @@ class StorageUrlResolver
|
|||
|
||||
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
|
||||
if ($queue = config('cdn.pipeline.queue')) {
|
||||
dispatch(new \Core\Cdn\Jobs\PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
|
||||
dispatch(new PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
|
||||
} else {
|
||||
$this->pushToCdn('hetzner-private', $path, 'private');
|
||||
}
|
||||
|
|
|
|||
7
src/Core/Cdn/Traits/CLAUDE.md
Normal file
7
src/Core/Cdn/Traits/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Cdn/Traits/ — CDN Model Traits
|
||||
|
||||
## Traits
|
||||
|
||||
| Trait | Purpose |
|
||||
|-------|---------|
|
||||
| `HasCdnUrls` | For models with asset paths needing CDN URL resolution. Requires `$cdnPathAttribute` (attribute with storage path) and optional `$cdnBucket` (`public` or `private`). Provides `cdnUrl()` accessor. |
|
||||
70
src/Core/Config/CLAUDE.md
Normal file
70
src/Core/Config/CLAUDE.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Config
|
||||
|
||||
Database-backed configuration with scoping, versioning, profiles, and admin UI.
|
||||
|
||||
## What It Does
|
||||
|
||||
Replaces/supplements Laravel's file-based config with a DB-backed system supporting:
|
||||
- Hierarchical scope resolution (global -> workspace -> user)
|
||||
- Configuration profiles (sets of values that can be switched)
|
||||
- Version history with diffs
|
||||
- Sensitive value encryption
|
||||
- Import/export (JSON/YAML)
|
||||
- Livewire admin panels
|
||||
- Event-driven invalidation
|
||||
|
||||
## Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Boot` | Listens to `AdminPanelBooting` and `ConsoleBooting` for registration |
|
||||
| `ConfigService` | Primary API: `get()`, `set()`, `isConfigured()`, plus scope-aware resolution |
|
||||
| `ConfigResolver` | Resolves values through scope hierarchy: user -> workspace -> global -> default |
|
||||
| `ConfigResult` | DTO wrapping resolved value with metadata (source scope, profile, etc.) |
|
||||
| `ConfigVersioning` | Tracks changes with diffs between versions |
|
||||
| `VersionDiff` | Computes and formats diffs between config versions |
|
||||
| `ConfigExporter` | Export/import config as JSON/YAML |
|
||||
| `ImportResult` | DTO for import operation results |
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Table | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `ConfigKey` | `config_keys` | Key definitions with type, default, validation rules, `is_sensitive` flag |
|
||||
| `ConfigValue` | `config_values` | Actual values scoped by type (global/workspace/user) |
|
||||
| `ConfigProfile` | `config_profiles` | Named sets of config values (soft-deletable) |
|
||||
| `ConfigVersion` | `config_versions` | Version history snapshots |
|
||||
| `ConfigResolved` | -- | Value object for resolved config |
|
||||
| `Channel` | -- | Notification channel config |
|
||||
|
||||
## Enums
|
||||
|
||||
- `ConfigType` -- Value types (string, int, bool, json, etc.)
|
||||
- `ScopeType` -- Resolution scopes (global, workspace, user)
|
||||
|
||||
## Events
|
||||
|
||||
- `ConfigChanged` -- Fired when any config value changes
|
||||
- `ConfigInvalidated` -- Fired when cache needs clearing
|
||||
- `ConfigLocked` -- Fired when a config key is locked
|
||||
|
||||
## Console Commands
|
||||
|
||||
- `config:prime` -- Pre-populate config cache
|
||||
- `config:list` -- List all config keys and values
|
||||
- `config:version` -- Show version history
|
||||
- `config:import` -- Import config from file
|
||||
- `config:export` -- Export config to file
|
||||
|
||||
## Admin UI
|
||||
|
||||
- `ConfigPanel` (Livewire) -- General config editing panel
|
||||
- `WorkspaceConfig` (Livewire) -- Workspace-specific config panel
|
||||
- Routes registered under admin prefix
|
||||
|
||||
## Integration
|
||||
|
||||
- `ConfigService` is used by other subsystems (e.g., `BunnyCdnService` reads CDN credentials via `$this->config->get('cdn.bunny.api_key')`)
|
||||
- Sensitive keys (`is_sensitive = true`) are encrypted at rest
|
||||
- Seeder: `ConfigKeySeeder` populates default keys
|
||||
- 4 migrations covering base tables, soft deletes, versions, and sensitive flag
|
||||
13
src/Core/Config/Console/CLAUDE.md
Normal file
13
src/Core/Config/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Config/Console/ — Config Artisan Commands
|
||||
|
||||
Artisan commands for managing the hierarchical configuration system.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Signature | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| `ConfigListCommand` | `config:list` | List config keys with resolved values. Filters by workspace, category, or configured-only. |
|
||||
| `ConfigPrimeCommand` | `config:prime` | Materialise resolved config into the fast-read table. Primes system and/or specific workspace. |
|
||||
| `ConfigExportCommand` | `config:export` | Export config to JSON or YAML file. Supports workspace scope and sensitive value inclusion. |
|
||||
| `ConfigImportCommand` | `config:import` | Import config from JSON or YAML file. Supports dry-run mode and automatic version snapshots. |
|
||||
| `ConfigVersionCommand` | `config:version` | Manage config versions — list, create snapshots, show, rollback, and compare versions. |
|
||||
|
|
@ -12,8 +12,11 @@ declare(strict_types=1);
|
|||
namespace Core\Config\Console;
|
||||
|
||||
use Core\Config\ConfigExporter;
|
||||
use Core\Config\Models\ConfigKey;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
|
||||
/**
|
||||
* Export config to JSON or YAML file.
|
||||
|
|
@ -45,13 +48,13 @@ class ConfigExportCommand extends Command
|
|||
// Resolve workspace
|
||||
$workspace = null;
|
||||
if ($workspaceSlug) {
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->components->error('Tenant module not installed. Cannot export workspace config.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->components->error("Workspace not found: {$workspaceSlug}");
|
||||
|
|
@ -96,16 +99,16 @@ class ConfigExportCommand extends Command
|
|||
/**
|
||||
* Get autocompletion suggestions.
|
||||
*/
|
||||
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
|
||||
if (class_exists(Workspace::class)) {
|
||||
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||
}
|
||||
}
|
||||
|
||||
if ($input->mustSuggestOptionValuesFor('category')) {
|
||||
$suggestions->suggestValues(\Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray());
|
||||
$suggestions->suggestValues(ConfigKey::distinct()->pluck('category')->toArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ namespace Core\Config\Console;
|
|||
|
||||
use Core\Config\ConfigExporter;
|
||||
use Core\Config\ConfigVersioning;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
|
||||
/**
|
||||
* Import config from JSON or YAML file.
|
||||
|
|
@ -53,13 +55,13 @@ class ConfigImportCommand extends Command
|
|||
// Resolve workspace
|
||||
$workspace = null;
|
||||
if ($workspaceSlug) {
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->components->error('Tenant module not installed. Cannot import workspace config.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->components->error("Workspace not found: {$workspaceSlug}");
|
||||
|
|
@ -174,11 +176,11 @@ class ConfigImportCommand extends Command
|
|||
/**
|
||||
* Get autocompletion suggestions.
|
||||
*/
|
||||
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
|
||||
if (class_exists(Workspace::class)) {
|
||||
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Core\Config\Console;
|
|||
|
||||
use Core\Config\ConfigService;
|
||||
use Core\Config\Models\ConfigKey;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ConfigListCommand extends Command
|
||||
|
|
@ -33,13 +34,13 @@ class ConfigListCommand extends Command
|
|||
$workspace = null;
|
||||
|
||||
if ($workspaceSlug) {
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->error('Tenant module not installed. Cannot filter by workspace.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->error("Workspace not found: {$workspaceSlug}");
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||
namespace Core\Config\Console;
|
||||
|
||||
use Core\Config\ConfigService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ConfigPrimeCommand extends Command
|
||||
|
|
@ -36,13 +37,13 @@ class ConfigPrimeCommand extends Command
|
|||
}
|
||||
|
||||
if ($workspaceSlug) {
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->error('Tenant module not installed. Cannot prime workspace config.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->error("Workspace not found: {$workspaceSlug}");
|
||||
|
|
@ -59,7 +60,7 @@ class ConfigPrimeCommand extends Command
|
|||
|
||||
$this->info('Priming config cache for all workspaces...');
|
||||
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->warn('Tenant module not installed. Only priming system config.');
|
||||
$config->prime(null);
|
||||
$this->info('System config cached.');
|
||||
|
|
@ -67,7 +68,7 @@ class ConfigPrimeCommand extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->withProgressBar(\Core\Tenant\Models\Workspace::all(), function ($workspace) use ($config) {
|
||||
$this->withProgressBar(Workspace::all(), function ($workspace) use ($config) {
|
||||
$config->prime($workspace);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ namespace Core\Config\Console;
|
|||
|
||||
use Core\Config\ConfigVersioning;
|
||||
use Core\Config\Models\ConfigVersion;
|
||||
use Core\Config\VersionDiff;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
|
||||
/**
|
||||
* Manage config versions.
|
||||
|
|
@ -50,13 +53,13 @@ class ConfigVersionCommand extends Command
|
|||
// Resolve workspace
|
||||
$workspace = null;
|
||||
if ($workspaceSlug) {
|
||||
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
if (! class_exists(Workspace::class)) {
|
||||
$this->components->error('Tenant module not installed. Cannot manage workspace versions.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
$this->components->error("Workspace not found: {$workspaceSlug}");
|
||||
|
|
@ -282,7 +285,7 @@ class ConfigVersionCommand extends Command
|
|||
/**
|
||||
* Display a diff.
|
||||
*/
|
||||
protected function displayDiff(\Core\Config\VersionDiff $diff): void
|
||||
protected function displayDiff(VersionDiff $diff): void
|
||||
{
|
||||
$this->components->info("Summary: {$diff->getSummary()}");
|
||||
$this->newLine();
|
||||
|
|
@ -402,15 +405,15 @@ class ConfigVersionCommand extends Command
|
|||
/**
|
||||
* Get autocompletion suggestions.
|
||||
*/
|
||||
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
if ($input->mustSuggestArgumentValuesFor('action')) {
|
||||
$suggestions->suggestValues(['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']);
|
||||
}
|
||||
|
||||
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
|
||||
if (class_exists(Workspace::class)) {
|
||||
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
src/Core/Config/Contracts/CLAUDE.md
Normal file
9
src/Core/Config/Contracts/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Config/Contracts/ — Config System Interfaces
|
||||
|
||||
## Interfaces
|
||||
|
||||
| Interface | Purpose |
|
||||
|-----------|---------|
|
||||
| `ConfigProvider` | Virtual configuration provider. Supplies config values at runtime without database storage. Matched against key patterns (e.g., `bio.*`). Registered via `ConfigResolver::registerProvider()`. |
|
||||
|
||||
Providers implement `pattern()` (wildcard key matching) and `resolve()` (returns the value for a given key, workspace, and channel context). Returns `null` to fall through to database resolution.
|
||||
|
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Contracts;
|
||||
|
||||
use Core\Config\ConfigResolver;
|
||||
use Core\Config\Models\Channel;
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +73,7 @@ use Core\Config\Models\Channel;
|
|||
* ```
|
||||
*
|
||||
*
|
||||
* @see \Core\Config\ConfigResolver::registerProvider()
|
||||
* @see ConfigResolver::registerProvider()
|
||||
*/
|
||||
interface ConfigProvider
|
||||
{
|
||||
|
|
|
|||
9
src/Core/Config/Database/Seeders/CLAUDE.md
Normal file
9
src/Core/Config/Database/Seeders/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Config/Database/Seeders/ — Config Key Seeders
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ConfigKeySeeder.php` | Seeds known configuration keys into the `config_keys` table. Defines CDN (Bunny), storage (Hetzner S3), social, analytics, and bio settings with their types, categories, and defaults. Uses `firstOrCreate` for idempotency. |
|
||||
|
||||
Part of the Config subsystem's M1 layer (key definitions). These keys are then assigned values via `ConfigValue` at system or workspace scope.
|
||||
12
src/Core/Config/Enums/CLAUDE.md
Normal file
12
src/Core/Config/Enums/CLAUDE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Config/Enums/ — Config Type System
|
||||
|
||||
Backed enums for the configuration system's type safety and scope hierarchy.
|
||||
|
||||
## Enums
|
||||
|
||||
| Enum | Values | Purpose |
|
||||
|------|--------|---------|
|
||||
| `ConfigType` | STRING, BOOL, INT, FLOAT, ARRAY, JSON | Determines how config values are cast and validated. Has `cast()` and `default()` methods. |
|
||||
| `ScopeType` | SYSTEM, ORG, WORKSPACE | Defines the inheritance hierarchy. Resolution order: workspace (priority 20) > org (10) > system (0). |
|
||||
|
||||
`ScopeType::resolutionOrder()` returns scopes from most specific to least specific for cascade resolution.
|
||||
13
src/Core/Config/Events/CLAUDE.md
Normal file
13
src/Core/Config/Events/CLAUDE.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Config/Events/ — Config System Events
|
||||
|
||||
Events dispatched by the configuration system for reactive integration.
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Fired When | Key Properties |
|
||||
|-------|-----------|----------------|
|
||||
| `ConfigChanged` | A config value is set or updated via `ConfigService::set()` | `keyCode`, `value`, `previousValue`, `profile`, `channelId` |
|
||||
| `ConfigInvalidated` | Config cache is manually cleared | `keyCode` (null = all), `workspaceId`, `channelId`. Has `isFull()` and `affectsKey()` helpers. |
|
||||
| `ConfigLocked` | A config value is locked (FINAL) | `keyCode`, `profile`, `channelId` |
|
||||
|
||||
Modules can listen to these events via the standard `$listens` pattern in their Boot class to react to config changes (e.g., refreshing CDN clients, flushing caches).
|
||||
14
src/Core/Config/Migrations/CLAUDE.md
Normal file
14
src/Core/Config/Migrations/CLAUDE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Config/Migrations/ — Config Schema Migrations
|
||||
|
||||
Database migrations for the hierarchical configuration system.
|
||||
|
||||
## Migrations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `0001_01_01_000001_create_config_tables.php` | Creates core config tables: `config_keys`, `config_profiles`, `config_values`, `config_channels`, `config_resolved`. |
|
||||
| `0001_01_01_000002_add_soft_deletes_to_config_profiles.php` | Adds soft delete support to `config_profiles`. |
|
||||
| `0001_01_01_000003_add_is_sensitive_to_config_keys.php` | Adds `is_sensitive` flag for automatic encryption of values. |
|
||||
| `0001_01_01_000004_create_config_versions_table.php` | Creates `config_versions` table for point-in-time snapshots and rollback. |
|
||||
|
||||
Uses early timestamps (`0001_01_01_*`) to run before application migrations.
|
||||
22
src/Core/Config/Models/CLAUDE.md
Normal file
22
src/Core/Config/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Config/Models/ — Config Eloquent Models
|
||||
|
||||
Eloquent models implementing the four-layer hierarchical configuration system.
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Table | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `ConfigKey` | `config_keys` | M1 layer — defines what keys exist. Dot-notation codes, typed (`ConfigType`), categorised. Supports sensitive flag for auto-encryption. Hierarchical parent/child grouping. |
|
||||
| `ConfigProfile` | `config_profiles` | M2 layer — groups values at a scope level (system/org/workspace). Inherits from parent profiles. Soft-deletable. |
|
||||
| `ConfigValue` | `config_values` | Junction table linking profiles to keys with actual values. `locked` flag implements FINAL (prevents child override). Auto-encrypts sensitive keys. Invalidates resolver hash on write. |
|
||||
| `ConfigVersion` | `config_versions` | Point-in-time snapshots for version history and rollback. Immutable (no `updated_at`). Stores JSON snapshot of all values. |
|
||||
| `Channel` | `config_channels` | Context dimension (web, api, mobile, instagram, etc.). Hierarchical inheritance chain with cycle detection. System or workspace-scoped. |
|
||||
| `ConfigResolved` | `config_resolved` | Materialised READ table — all lookups hit this directly. No computation at read time. Populated by the `prime` operation. Composite key (workspace_id, channel_id, key_code). |
|
||||
|
||||
## Resolution Flow
|
||||
|
||||
```
|
||||
ConfigService::get() → ConfigResolved (fast lookup)
|
||||
→ miss: ConfigResolver computes from ConfigValue chain
|
||||
→ stores result back to ConfigResolved + in-memory hash
|
||||
```
|
||||
|
|
@ -11,6 +11,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -35,8 +37,8 @@ use Illuminate\Support\Facades\Log;
|
|||
* @property int|null $parent_id
|
||||
* @property int|null $workspace_id
|
||||
* @property array|null $metadata
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Channel extends Model
|
||||
{
|
||||
|
|
@ -77,8 +79,8 @@ class Channel extends Model
|
|||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
|
||||
if (class_exists(Workspace::class)) {
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// Return a null relationship when Tenant module is not installed
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Config\Enums\ConfigType;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -30,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
* @property string|null $description
|
||||
* @property mixed $default_value
|
||||
* @property bool $is_sensitive
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ConfigKey extends Model
|
||||
{
|
||||
|
|
@ -108,9 +110,9 @@ class ConfigKey extends Model
|
|||
/**
|
||||
* Get all keys for a category.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forCategory(string $category): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forCategory(string $category): Collection
|
||||
{
|
||||
return static::where('category', $category)->get();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Config\Enums\ScopeType;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -29,9 +31,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int|null $scope_id
|
||||
* @property int|null $parent_profile_id
|
||||
* @property int $priority
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
*/
|
||||
class ConfigProfile extends Model
|
||||
{
|
||||
|
|
@ -90,9 +92,9 @@ class ConfigProfile extends Model
|
|||
/**
|
||||
* Get profiles for a scope.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forScope(ScopeType $type, ?int $scopeId = null): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forScope(ScopeType $type, ?int $scopeId = null): Collection
|
||||
{
|
||||
return static::where('scope_type', $type)
|
||||
->where('scope_id', $scopeId)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Config\ConfigResult;
|
||||
use Core\Config\Enums\ConfigType;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -36,7 +39,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property int|null $source_profile_id
|
||||
* @property int|null $source_channel_id
|
||||
* @property bool $virtual
|
||||
* @property \Carbon\Carbon $computed_at
|
||||
* @property Carbon $computed_at
|
||||
*/
|
||||
class ConfigResolved extends Model
|
||||
{
|
||||
|
|
@ -71,8 +74,8 @@ class ConfigResolved extends Model
|
|||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
|
||||
if (class_exists(Workspace::class)) {
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// Return a null relationship when Tenant module is not installed
|
||||
|
|
@ -155,9 +158,9 @@ class ConfigResolved extends Model
|
|||
/**
|
||||
* Get all resolved config for a scope.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forScope(?int $workspaceId = null, ?int $channelId = null): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forScope(?int $workspaceId = null, ?int $channelId = null): Collection
|
||||
{
|
||||
return static::where('workspace_id', $workspaceId)
|
||||
->where('channel_id', $channelId)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Config\ConfigResolver;
|
||||
use Core\Config\Enums\ScopeType;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
|
@ -31,8 +34,8 @@ use Illuminate\Support\Facades\Crypt;
|
|||
* @property mixed $value
|
||||
* @property bool $locked
|
||||
* @property int|null $inherited_from
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ConfigValue extends Model
|
||||
{
|
||||
|
|
@ -76,7 +79,7 @@ class ConfigValue extends Model
|
|||
$encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX));
|
||||
|
||||
return json_decode(Crypt::decryptString($encrypted), true);
|
||||
} catch (\Illuminate\Contracts\Encryption\DecryptException) {
|
||||
} catch (DecryptException) {
|
||||
// Return null if decryption fails (key rotation, corruption, etc.)
|
||||
return null;
|
||||
}
|
||||
|
|
@ -255,9 +258,9 @@ class ConfigValue extends Model
|
|||
*
|
||||
* @param array<int> $profileIds
|
||||
* @param array<int>|null $channelIds Include null for "all channels" values
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): Collection
|
||||
{
|
||||
return static::where('key_id', $keyId)
|
||||
->whereIn('profile_id', $profileIds)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Config\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -26,7 +29,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property string $label
|
||||
* @property string $snapshot
|
||||
* @property string|null $author
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class ConfigVersion extends Model
|
||||
{
|
||||
|
|
@ -65,8 +68,8 @@ class ConfigVersion extends Model
|
|||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
|
||||
if (class_exists(Workspace::class)) {
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// Return a null relationship when Tenant module is not installed
|
||||
|
|
@ -136,9 +139,9 @@ class ConfigVersion extends Model
|
|||
* Get versions for a scope.
|
||||
*
|
||||
* @param int|null $workspaceId Workspace ID or null for system
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function forScope(?int $workspaceId = null): \Illuminate\Database\Eloquent\Collection
|
||||
public static function forScope(?int $workspaceId = null): Collection
|
||||
{
|
||||
return static::where('workspace_id', $workspaceId)
|
||||
->orderByDesc('created_at')
|
||||
|
|
@ -160,9 +163,9 @@ class ConfigVersion extends Model
|
|||
/**
|
||||
* Get versions created by a specific author.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function byAuthor(string $author): \Illuminate\Database\Eloquent\Collection
|
||||
public static function byAuthor(string $author): Collection
|
||||
{
|
||||
return static::where('author', $author)
|
||||
->orderByDesc('created_at')
|
||||
|
|
@ -172,11 +175,11 @@ class ConfigVersion extends Model
|
|||
/**
|
||||
* Get versions created within a date range.
|
||||
*
|
||||
* @param \Carbon\Carbon $from Start date
|
||||
* @param \Carbon\Carbon $to End date
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
* @param Carbon $from Start date
|
||||
* @param Carbon $to End date
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function inDateRange(\Carbon\Carbon $from, \Carbon\Carbon $to): \Illuminate\Database\Eloquent\Collection
|
||||
public static function inDateRange(Carbon $from, Carbon $to): Collection
|
||||
{
|
||||
return static::whereBetween('created_at', [$from, $to])
|
||||
->orderByDesc('created_at')
|
||||
|
|
|
|||
7
src/Core/Config/Routes/CLAUDE.md
Normal file
7
src/Core/Config/Routes/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Config/Routes/ — Config Admin Routes
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `admin.php` | Admin route definitions for the configuration panel. Registers routes under the `admin` middleware group for the `ConfigPanel` and `WorkspaceConfig` Livewire components. |
|
||||
11
src/Core/Config/Tests/Feature/CLAUDE.md
Normal file
11
src/Core/Config/Tests/Feature/CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Config/Tests/Feature/ — Config Integration Tests
|
||||
|
||||
Pest feature tests for the hierarchical configuration system.
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ConfigServiceTest.php` | Full integration tests covering ConfigKey creation, ConfigProfile inheritance, ConfigResolver scope cascading, FINAL lock enforcement, ConfigService materialised reads/writes, ConfigResolved storage, and the single-hash lazy-load pattern. |
|
||||
|
||||
Tests cover the complete config lifecycle: key definition, profile hierarchy (system/workspace), value resolution with inheritance, lock semantics, cache invalidation, and the prime/materialise flow.
|
||||
|
|
@ -19,8 +19,9 @@ use Core\Config\Models\ConfigProfile;
|
|||
use Core\Config\Models\ConfigResolved;
|
||||
use Core\Config\Models\ConfigValue;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear hash for clean test state
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue