Add platform admin hosting API for Ladill account provisioning.
Deploy Ladill Hosting / deploy (push) Successful in 1m29s

Expose authenticated service endpoints for assigning, listing, renewing, and managing hosting accounts from the platform, with tests and deploy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 22:54:00 +00:00
co-authored by Cursor
parent b0e1cfab4e
commit 2c9877a87c
14 changed files with 806 additions and 66 deletions
+7
View File
@@ -65,10 +65,17 @@ On the **platform** `.env`:
```env
BILLING_API_KEY_HOSTING=<same as this app>
IDENTITY_API_KEY_HOSTING=<same as this app>
HOSTING_API_KEY_ACCOUNT=<same as PLATFORM_API_KEY_HOSTING below>
RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel
LADILL_HOSTING_APP_URL=https://hosting.ladill.com
```
On this app's `.env`:
```env
PLATFORM_API_KEY_HOSTING=<same as platform HOSTING_API_KEY_ACCOUNT>
```
Then `php artisan config:cache` on the platform.
## 5. nginx + TLS
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\HostingAccount;
use App\Services\Hosting\AdminHostingAssignmentService;
use App\Services\Hosting\HostingResourcePolicyService;
use App\Services\Platform\PlatformUserResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PlatformHostingController extends Controller
{
public function products(AdminHostingAssignmentService $assignments): JsonResponse
{
return response()->json([
'data' => $assignments->listProducts(),
]);
}
public function index(string $publicId, PlatformUserResolver $users, AdminHostingAssignmentService $assignments): JsonResponse
{
$owner = $users->resolveOrCreate($publicId);
if (! $owner) {
return response()->json(['data' => []]);
}
$accounts = HostingAccount::query()
->where('user_id', $owner->id)
->with('product')
->latest()
->get()
->map(fn (HostingAccount $account) => $assignments->serializeAccount($account))
->values()
->all();
return response()->json(['data' => $accounts]);
}
public function assign(
Request $request,
string $publicId,
PlatformUserResolver $users,
AdminHostingAssignmentService $assignments,
): JsonResponse {
$validated = $request->validate([
'hosting_product_id' => ['required', 'integer', 'exists:hosting_products,id'],
'duration_months' => ['required', 'integer', 'min:1', 'max:120'],
'primary_domain' => ['nullable', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:32'],
'notes' => ['nullable', 'string', 'max:1000'],
'owner_email' => ['required', 'email', 'max:255'],
'owner_name' => ['nullable', 'string', 'max:255'],
'assigned_by_admin' => ['nullable', 'integer'],
]);
$owner = $users->resolveOrCreate(
$publicId,
(string) $validated['owner_email'],
(string) ($validated['owner_name'] ?? ''),
);
if (! $owner) {
return response()->json(['error' => 'User not found.'], 404);
}
$result = $assignments->assign($owner, $validated);
return response()->json(['data' => $result], 201);
}
public function updateDuration(
Request $request,
int $account,
AdminHostingAssignmentService $assignments,
): JsonResponse {
$validated = $request->validate([
'duration_months' => ['required', 'integer', 'min:1', 'max:120'],
'admin_id' => ['nullable', 'integer'],
]);
$hostingAccount = HostingAccount::query()->findOrFail($account);
$data = $assignments->updateDuration(
$hostingAccount,
(int) $validated['duration_months'],
isset($validated['admin_id']) ? (int) $validated['admin_id'] : null,
);
return response()->json(['data' => $data]);
}
public function renew(
Request $request,
int $account,
AdminHostingAssignmentService $assignments,
): JsonResponse {
$validated = $request->validate([
'admin_id' => ['nullable', 'integer'],
]);
$hostingAccount = HostingAccount::query()->findOrFail($account);
$data = $assignments->renew(
$hostingAccount,
isset($validated['admin_id']) ? (int) $validated['admin_id'] : null,
);
return response()->json(['data' => $data]);
}
public function unsuspend(
int $account,
AdminHostingAssignmentService $assignments,
HostingResourcePolicyService $resourcePolicy,
): JsonResponse {
$hostingAccount = HostingAccount::query()->findOrFail($account);
$assignments->unsuspend($hostingAccount, $resourcePolicy);
return response()->json(['data' => ['ok' => true]]);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Generic service-to-service auth for internal APIs. Validates the bearer token
* against per-consumer keys in config("{namespace}.service_api_keys").
*/
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'platform'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
+39 -37
View File
@@ -2,6 +2,7 @@
namespace App\Services\Domains;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -11,9 +12,11 @@ class LadillDomainsClient
/** @return list<string> Active domain names owned by the account (lowercase). */
public function ownedForUser(string $publicId): array
{
[$storefront, $platform] = $this->fetchOwnedDomainsInParallel($publicId);
return collect()
->merge($this->fetchFromStorefront($publicId))
->merge($this->fetchFromPlatform($publicId))
->merge($storefront)
->merge($platform)
->map(fn ($d) => strtolower(trim((string) $d)))
->filter()
->unique()
@@ -22,61 +25,60 @@ class LadillDomainsClient
->all();
}
/** @return list<string> */
private function fetchFromStorefront(string $publicId): array
/**
* @return array{0: list<string>, 1: list<string>}
*/
private function fetchOwnedDomainsInParallel(string $publicId): array
{
$key = (string) (config('domains.api_key') ?? '');
if ($key === '') {
return [];
}
$storefront = [];
$platform = [];
try {
$res = Http::withToken($key)
$responses = Http::pool(function ($pool) use ($publicId) {
$requests = [];
$storefrontKey = (string) (config('domains.api_key') ?? '');
if ($storefrontKey !== '') {
$requests['storefront'] = $pool->as('storefront')
->withToken($storefrontKey)
->acceptJson()
->timeout(10)
->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [
'user' => $publicId,
]);
$res->throw();
return (array) $res->json('data', []);
} catch (\Throwable $e) {
Log::warning('LadillDomainsClient: storefront owned domains unavailable', [
'user' => $publicId,
'error' => $e->getMessage(),
]);
return [];
}
}
/** @return list<string> */
private function fetchFromPlatform(string $publicId): array
{
$key = (string) (config('domains.platform_api_key') ?? '');
if ($key === '') {
return [];
}
try {
$res = Http::withToken($key)
$platformKey = (string) (config('domains.platform_api_key') ?? '');
if ($platformKey !== '') {
$requests['platform'] = $pool->as('platform')
->withToken($platformKey)
->acceptJson()
->timeout(10)
->get(rtrim((string) config('domains.platform_api_url'), '/').'/owned-domains', [
'user' => $publicId,
]);
}
$res->throw();
return $requests;
});
return (array) $res->json('data', []);
foreach ($responses as $name => $response) {
if (! $response instanceof Response || ! $response->successful()) {
continue;
}
$data = (array) $response->json('data', []);
if ($name === 'storefront') {
$storefront = $data;
} elseif ($name === 'platform') {
$platform = $data;
}
}
} catch (\Throwable $e) {
Log::warning('LadillDomainsClient: platform owned domains unavailable', [
Log::warning('LadillDomainsClient: owned domains lookup failed', [
'user' => $publicId,
'error' => $e->getMessage(),
]);
}
return [];
}
return [$storefront, $platform];
}
}
@@ -0,0 +1,243 @@
<?php
namespace App\Services\Hosting;
use App\Jobs\ProvisionHostingAccountJob;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AdminHostingAssignmentService
{
public function __construct(
private NodeCapacityService $nodeCapacity,
private HostingResourcePolicyService $resourcePolicy,
) {}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function assign(User $owner, array $input): array
{
$product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']);
if (! $product->is_active) {
throw ValidationException::withMessages([
'hosting_product_id' => 'Selected hosting product is not active.',
]);
}
$username = isset($input['username']) && $input['username'] !== ''
? (string) $input['username']
: $this->generateUsername($owner);
if (HostingAccount::query()->where('username', $username)->exists()) {
throw ValidationException::withMessages([
'username' => 'This username is already taken.',
]);
}
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
? (string) $input['primary_domain']
: null;
$accountType = match ($product->type) {
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
'vps' => HostingAccount::TYPE_VPS,
'dedicated' => HostingAccount::TYPE_DEDICATED,
'email_standalone' => HostingAccount::TYPE_SHARED,
default => HostingAccount::TYPE_SHARED,
};
$node = null;
$nodeId = null;
if ($accountType === HostingAccount::TYPE_SHARED) {
$node = $this->nodeCapacity->findAvailableNodeForProduct($product);
$nodeId = $node?->id;
}
$resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product);
$durationMonths = (int) $input['duration_months'];
$hostingAccount = HostingAccount::create([
'user_id' => $owner->id,
'hosting_product_id' => $product->id,
'hosting_node_id' => $nodeId,
'username' => $username,
'primary_domain' => $primaryDomain,
'type' => $accountType,
'status' => HostingAccount::STATUS_PENDING,
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'expires_at' => $this->expiresAtFromDuration($durationMonths),
'resource_limits' => array_merge([
'disk_gb' => (int) ($product->disk_gb ?? 0),
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
], $resourceLimits),
'metadata' => [
'assigned_by_admin' => $input['assigned_by_admin'] ?? null,
'assigned_at' => now()->toISOString(),
'assigned_duration_months' => $durationMonths,
'notes' => $input['notes'] ?? null,
],
]);
if ($node) {
ProvisionHostingAccountJob::dispatch($hostingAccount->id);
return [
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
'provisioning' => true,
'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.",
];
}
$hostingAccount->update([
'status' => HostingAccount::STATUS_ACTIVE,
'provisioned_at' => now(),
]);
return [
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
'provisioning' => false,
'message' => "Hosting product '{$product->name}' assigned.",
];
}
public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array
{
$metadata = (array) ($account->metadata ?? []);
$metadata['assigned_duration_months'] = $durationMonths;
$metadata['duration_updated_by_admin'] = $adminId;
$metadata['duration_updated_at'] = now()->toISOString();
$account->update([
'expires_at' => $this->expiresAtFromDuration($durationMonths),
'metadata' => $metadata,
]);
return $this->serializeAccount($account->fresh(['product']));
}
public function renew(HostingAccount $account, ?int $adminId = null): array
{
if (! $account->canBeRenewed()) {
throw ValidationException::withMessages([
'account' => 'This hosting account does not have a configured renewal duration.',
]);
}
$months = $account->assignedDurationMonths();
$account->renew($months, [
'renewed_by_admin' => $adminId,
'renewed_at' => now()->toISOString(),
]);
return $this->serializeAccount($account->fresh(['product']));
}
public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void
{
if (
$account->status !== HostingAccount::STATUS_SUSPENDED
&& $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED
) {
throw ValidationException::withMessages([
'account' => 'This hosting account is not suspended.',
]);
}
$resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.');
}
/**
* @return array<string, mixed>
*/
public function serializeAccount(HostingAccount $account): array
{
$product = $account->product;
return [
'id' => $account->id,
'username' => $account->username,
'primary_domain' => $account->primary_domain,
'type' => $account->type,
'status' => $account->status,
'resource_status' => $account->resource_status,
'expires_at' => $account->expires_at?->toIso8601String(),
'provisioned_at' => $account->provisioned_at?->toIso8601String(),
'metadata' => (array) ($account->metadata ?? []),
'product' => $product ? [
'id' => $product->id,
'name' => $product->name,
'type' => $product->type,
'slug' => $product->slug,
] : null,
];
}
/**
* @return list<array<string, mixed>>
*/
public function listProducts(): array
{
return HostingProduct::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn (HostingProduct $product) => [
'id' => $product->id,
'name' => $product->name,
'type' => $product->type,
'slug' => $product->slug,
'display_currency' => $product->display_currency,
'display_price_monthly' => $product->display_price_monthly,
])
->values()
->all();
}
private function generateUsername(User $user): string
{
$baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name));
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
if (strlen($baseUsername) < 3) {
$baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email)));
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
}
if (strlen($baseUsername) < 3) {
$baseUsername = 'user'.$user->id;
}
$username = substr($baseUsername, 0, 16);
$counter = 1;
$originalUsername = $username;
while (HostingAccount::query()->where('username', $username)->exists()) {
$suffix = (string) $counter;
$maxBaseLength = 16 - strlen($suffix);
$username = substr($originalUsername, 0, $maxBaseLength).$suffix;
$counter++;
}
return $username;
}
private function expiresAtFromDuration(int $durationMonths): CarbonInterface
{
return now()->addMonthsNoOverflow($durationMonths);
}
}
@@ -909,16 +909,7 @@ NGINX;
$domain = $site->domain;
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
// Create document root
$quotedDocRoot = escapeshellarg($docRoot);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
"Failed to create document root for {$domain}"
);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'chown', ["{$username}:{$username}", $docRoot], "chown {$username}:{$username} {$quotedDocRoot}"),
"Failed to assign document root ownership for {$domain}"
);
$this->ensureSiteDocumentRoot($ssh, $username, $docRoot);
$this->hardenAccountFilesystem($account, [$docRoot]);
$this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [
@@ -931,6 +922,24 @@ NGINX;
];
}
private function ensureSiteDocumentRoot(mixed $ssh, string $username, string $docRoot): void
{
$quotedDocRoot = escapeshellarg($docRoot);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
"Failed to create document root at {$docRoot}"
);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote(
$ssh,
'run-cmd',
["chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"],
"chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"
),
"Failed to set document root ownership at {$docRoot}"
);
}
public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void
{
$node = $account->node;
@@ -1002,6 +1011,7 @@ NGINX;
$domain = $site->domain;
$email = $account->user?->email ?: 'admin@' . $domain;
$docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}";
$this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot);
$hadCertificate = $this->siteHasCertificate($ssh, $domain);
Log::info("SSL: Starting certificate request", [
@@ -1677,7 +1687,7 @@ LIMITS;
'runtime_port' => $port,
'runtime_type' => $appType,
]),
]);
])->save();
try {
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
@@ -2451,14 +2461,7 @@ SERVICE;
NGINX;
if ($proxyPort !== null) {
$httpBody = <<<NGINX
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
location / {
proxy_pass http://127.0.0.1:{$proxyPort};
$proxyHeaders = <<<NGINX
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
@@ -2469,6 +2472,27 @@ NGINX;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
NGINX;
$apiPort = data_get($site->app_config, 'api_runtime_port');
$apiLocation = is_numeric($apiPort)
? <<<NGINX
location /api/ {
proxy_pass http://127.0.0.1:{$apiPort};
{$proxyHeaders}
}
NGINX
: '';
$httpBody = <<<NGINX
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
{$apiLocation}
location / {
proxy_pass http://127.0.0.1:{$proxyPort};
{$proxyHeaders}
}
client_max_body_size 64M;
@@ -0,0 +1,31 @@
<?php
namespace App\Services\Platform;
use App\Models\User;
class PlatformUserResolver
{
public function resolveOrCreate(string $publicId, string $email = '', string $name = ''): ?User
{
$publicId = trim($publicId);
if ($publicId === '') {
return null;
}
$user = User::query()->where('public_id', $publicId)->first();
if ($user) {
return $user;
}
if ($email === '') {
return null;
}
return User::query()->create([
'public_id' => $publicId,
'email' => $email,
'name' => $name !== '' ? $name : $email,
]);
}
}
+1
View File
@@ -22,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
]);
$middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
]);
// External registrar webhook posts have no CSRF token.
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Platform service-to-service API keys
|--------------------------------------------------------------------------
| Bearer tokens for internal APIs called by account.ladill.com (the platform).
| The hosting app is the sole writer for hosting accounts; admin assignment
| and lifecycle actions arrive over these routes.
*/
'service_api_keys' => array_filter([
'account' => env('PLATFORM_API_KEY_HOSTING'),
]),
];
+2
View File
@@ -14,9 +14,11 @@ registerLadillDomainPurchase();
import Alpine from 'alpinejs';
import { registerLadillClipboard } from './ladill-clipboard';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
+57
View File
@@ -0,0 +1,57 @@
const COPY_FEEDBACK_MS = 2000;
export async function writeClipboardText(text) {
const value = String(text ?? '');
if (!value) {
return false;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// fall through to legacy copy
}
try {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
export function registerLadillClipboard(Alpine) {
Alpine.data('copyButton', (text = '') => ({
copied: false,
text: text ?? '',
resetTimer: null,
async copy() {
const ok = await writeClipboardText(this.text);
if (!ok) {
return;
}
this.copied = true;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
}
this.resetTimer = setTimeout(() => {
this.copied = false;
}, COPY_FEEDBACK_MS);
},
}));
}
@@ -0,0 +1,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
+15
View File
@@ -1,9 +1,24 @@
<?php
use App\Http\Controllers\Api\PlatformHostingController;
use App\Services\Mailbox\MailboxClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth.service:platform')->prefix('platform')->group(function (): void {
Route::get('/hosting-products', [PlatformHostingController::class, 'products']);
Route::get('/users/{publicId}/hosting-accounts', [PlatformHostingController::class, 'index'])
->where('publicId', '[A-Za-z0-9\-]+');
Route::post('/users/{publicId}/hosting-accounts', [PlatformHostingController::class, 'assign'])
->where('publicId', '[A-Za-z0-9\-]+');
Route::patch('/hosting-accounts/{account}/duration', [PlatformHostingController::class, 'updateDuration'])
->whereNumber('account');
Route::post('/hosting-accounts/{account}/renew', [PlatformHostingController::class, 'renew'])
->whereNumber('account');
Route::post('/hosting-accounts/{account}/unsuspend', [PlatformHostingController::class, 'unsuspend'])
->whereNumber('account');
});
// Ladill Email API (v1) — authenticated with Sanctum personal access tokens
// minted on the Developers page. Read-only for now: identity + mailboxes.
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PlatformHostingAdminApiTest extends TestCase
{
use RefreshDatabase;
private const API_KEY = 'test-platform-hosting-key';
protected function setUp(): void
{
parent::setUp();
config(['platform.service_api_keys' => ['account' => self::API_KEY]]);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_platform_can_assign_hosting_package_to_user(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$owner = User::query()->create([
'public_id' => (string) \Illuminate\Support\Str::uuid(),
'name' => 'Hosting Owner',
'email' => 'owner@example.com',
]);
$product = $this->createHostingProduct();
$response = $this->withToken(self::API_KEY)
->postJson('/api/platform/users/'.$owner->public_id.'/hosting-accounts', [
'hosting_product_id' => $product->id,
'duration_months' => 6,
'primary_domain' => 'example.com',
'username' => 'exampleuser',
'owner_email' => $owner->email,
'owner_name' => $owner->name,
'assigned_by_admin' => 99,
]);
$response->assertCreated()
->assertJsonPath('data.account.username', 'exampleuser')
->assertJsonPath('data.account.primary_domain', 'example.com')
->assertJsonPath('data.account.status', 'active')
->assertJsonPath('data.account.metadata.assigned_duration_months', 6)
->assertJsonPath('data.account.metadata.assigned_by_admin', 99);
$account = HostingAccount::query()->where('user_id', $owner->id)->firstOrFail();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-10-05 10:00:00')));
}
public function test_platform_can_list_user_hosting_accounts(): void
{
$owner = User::query()->create([
'public_id' => (string) \Illuminate\Support\Str::uuid(),
'name' => 'Hosting Owner',
'email' => 'owner@example.com',
]);
$product = $this->createHostingProduct();
HostingAccount::query()->create([
'user_id' => $owner->id,
'hosting_product_id' => $product->id,
'username' => 'listeduser',
'type' => 'shared',
'status' => 'active',
'metadata' => ['assigned_duration_months' => 3],
]);
$response = $this->withToken(self::API_KEY)
->getJson('/api/platform/users/'.$owner->public_id.'/hosting-accounts');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.username', 'listeduser');
}
public function test_platform_can_update_duration_and_renew(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$owner = User::query()->create([
'public_id' => (string) \Illuminate\Support\Str::uuid(),
'name' => 'Hosting Owner',
'email' => 'owner@example.com',
]);
$product = $this->createHostingProduct();
$account = HostingAccount::query()->create([
'user_id' => $owner->id,
'hosting_product_id' => $product->id,
'username' => 'durationuser',
'type' => 'shared',
'status' => 'active',
'expires_at' => Carbon::parse('2026-05-05 10:00:00'),
'metadata' => ['assigned_duration_months' => 1],
]);
$this->withToken(self::API_KEY)
->patchJson('/api/platform/hosting-accounts/'.$account->id.'/duration', [
'duration_months' => 12,
'admin_id' => 7,
])
->assertOk()
->assertJsonPath('data.metadata.assigned_duration_months', 12);
$account->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00')));
$this->withToken(self::API_KEY)
->postJson('/api/platform/hosting-accounts/'.$account->id.'/renew', ['admin_id' => 7])
->assertOk();
$account->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2028-04-05 10:00:00')));
}
public function test_unauthenticated_platform_requests_are_rejected(): void
{
$this->getJson('/api/platform/hosting-products')->assertUnauthorized();
}
private function createHostingProduct(): HostingProduct
{
return HostingProduct::query()->create([
'name' => 'Admin Assigned Shared Hosting',
'slug' => 'admin-assigned-shared-hosting',
'category' => 'shared',
'type' => 'single_domain',
'price_monthly' => 10,
'price_quarterly' => 30,
'price_yearly' => 120,
'price_biennial' => 240,
'currency' => 'GHS',
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
'sort_order' => 1,
]);
}
}