diff --git a/rsync-studio-licensing/.editorconfig b/rsync-studio-licensing/.editorconfig
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/.env.example b/rsync-studio-licensing/.env.example
old mode 100644
new mode 100755
index ed05089..6b9ff41
--- a/rsync-studio-licensing/.env.example
+++ b/rsync-studio-licensing/.env.example
@@ -72,6 +72,16 @@ RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1
RSYNC_STUDIO_PURCHASE_URL=
RSYNC_STUDIO_PORTAL_URL=
RSYNC_STUDIO_SUPPORT_URL=
+PADDLE_API_KEY=
+PADDLE_API_URL=https://sandbox-api.paddle.com
+PADDLE_CLIENT_SIDE_TOKEN=
+PADDLE_ENVIRONMENT=sandbox
PADDLE_WEBHOOK_SECRET=
PADDLE_WEBHOOK_TOLERANCE=300
+PADDLE_PERSONAL_PRODUCT_ID=
+PADDLE_PERSONAL_MONTHLY_PRICE_ID=
+PADDLE_PERSONAL_ANNUAL_PRICE_ID=
+PADDLE_PROFESSIONAL_PRODUCT_ID=
+PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=
+PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=
LEMON_SQUEEZY_WEBHOOK_SECRET=
diff --git a/rsync-studio-licensing/.gitattributes b/rsync-studio-licensing/.gitattributes
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/.gitignore b/rsync-studio-licensing/.gitignore
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/.npmrc b/rsync-studio-licensing/.npmrc
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/AGENTS.md b/rsync-studio-licensing/AGENTS.md
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/CLAUDE.md b/rsync-studio-licensing/CLAUDE.md
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/README.md b/rsync-studio-licensing/README.md
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php
old mode 100644
new mode 100755
index 6671411..ee52644
--- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php
+++ b/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php
@@ -1,10 +1,36 @@
argument('email'))); $password=Str::password(24,true,true,false,false); $u=User::firstOrNew(['email'=>$email]); $u->name=$this->option('name'); $u->is_admin=true; $u->email_verified_at=now(); if(!$u->exists){$u->password=Hash::make($password);} $u->save(); $this->info("Administrator ready: {$email}"); if(!$u->wasRecentlyCreated){$this->line('Existing password retained.');}else{$this->warn("Temporary password (shown once): {$password}");} return self::SUCCESS; }
+
+final class RsyncStudioAdminCreate extends Command
+{
+ protected $signature = 'rsync-studio:admin {email} {--name=Administrator}';
+
+ protected $description = 'Create or promote an Rsync Studio licensing administrator';
+
+ public function handle(): int
+ {
+ $email = strtolower(trim($this->argument('email')));
+ $password = Str::password(24, true, true, false, false);
+ $u = User::firstOrNew(['email' => $email]);
+ $u->name = $this->option('name');
+ $u->is_admin = true;
+ $u->email_verified_at = now();
+ if (! $u->exists) {
+ $u->password = Hash::make($password);
+ } $u->save();
+ $this->info("Administrator ready: {$email}");
+ if (! $u->wasRecentlyCreated) {
+ $this->line('Existing password retained.');
+ } else {
+ $this->warn("Temporary password (shown once): {$password}");
+ }
+
+return self::SUCCESS;
+ }
}
diff --git a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php
old mode 100644
new mode 100755
index 886cefd..aa5d895
--- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php
+++ b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php
@@ -1,10 +1,29 @@
argument('product'))->firstOrFail(); [$l,$key]=$issuer->issue($p,$this->option('name')?:null,$this->option('email')?:null); if(!$key){$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');return self::FAILURE;} $this->line('Licence ID: '.$l->id);$this->line('Licence key: '.$key);$this->warn('This is the only time the plaintext key is shown.');return self::SUCCESS; }
+ protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
+
+ protected $description = 'Issue a manual Rsync Studio licence key';
+
+ public function handle(LicenseIssuer $issuer): int
+ {
+ $p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
+ [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
+ if (! $key) {
+ $this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
+
+ return self::FAILURE;
+ } $this->line('Licence ID: '.$l->id);
+ $this->line('Licence key: '.$key);
+ $this->warn('This is the only time the plaintext key is shown.');
+
+ return self::SUCCESS;
+ }
}
diff --git a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php
old mode 100644
new mode 100755
index c608df3..f48204f
--- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php
+++ b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php
@@ -1,9 +1,22 @@
argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; }
+ protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
+
+ protected $description = 'Revoke a Rsync Studio licence';
+
+ public function handle(): int
+ {
+ $l = License::findOrFail($this->argument('id'));
+ $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
+ $this->info('Licence revoked.');
+
+ return self::SUCCESS;
+ }
}
diff --git a/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php
old mode 100644
new mode 100755
index 098b20d..6191cb4
--- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php
+++ b/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php
@@ -1,10 +1,21 @@
$this->argument('code')],['name'=>$this->option('name'),'edition'=>$this->option('edition'),'activation_limit'=>(int)$this->option('activations'),'max_major_version'=>$this->option('max-major')!==''?(int)$this->option('max-major'):null,'payment_provider'=>$this->option('provider')?:null,'external_product_id'=>$this->option('product-id')?:null,'external_price_id'=>$this->option('price-id')?:null,'active'=>true]); $this->info("Product {$p->code} ready."); return self::SUCCESS; }
+ protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
+
+ protected $description = 'Create or update a Rsync Studio licensing product';
+
+ public function handle(): int
+ {
+ $p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
+ $this->info("Product {$p->code} ready.");
+
+ return self::SUCCESS;
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php b/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php
old mode 100644
new mode 100755
index 1d87ddc..6c2bb25
--- a/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php
@@ -1,14 +1,49 @@
License::count(),'activeCount'=>License::whereNull('revoked_at')->count(),'activationCount'=>\App\Models\LicenseActivation::whereNull('deactivated_at')->count(),'userCount'=>User::count()]); }
- public function licenses(Request $r){ $q=License::with('product')->latest(); if($s=trim((string)$r->query('q'))) $q->where(fn($x)=>$x->where('customer_email','like',"%{$s}%")->orWhere('customer_name','like',"%{$s}%")->orWhere('license_key_last4','like',"%{$s}%")); return view('admin.licenses',['licenses'=>$q->paginate(50)->withQueryString()]); }
- public function show(License $license){$license->load(['product','activations']); return view('admin.license',compact('license'));}
- public function revoke(Request $request,License $license){$data=$request->validate(['reason'=>['nullable','string','max:255']]);$license->update(['revoked_at'=>now(),'revoke_reason'=>$data['reason']?:'Revoked by administrator']);return back()->with('status','Licence revoked.');}
- public function restore(License $license){$license->update(['revoked_at'=>null,'revoke_reason'=>null]);return back()->with('status','Licence restored.');}
+
+final class AdminController extends Controller
+{
+ public function index()
+ {
+ return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
+ }
+
+ public function licenses(Request $r)
+ {
+ $q = License::with('product')->latest();
+ if ($s = trim((string) $r->query('q'))) {
+ $q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
+ }
+
+return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
+ }
+
+ public function show(License $license)
+ {
+ $license->load(['product', 'activations']);
+
+ return view('admin.license', compact('license'));
+ }
+
+ public function revoke(Request $request, License $license)
+ {
+ $data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
+ $license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
+
+ return back()->with('status', 'Licence revoked.');
+ }
+
+ public function restore(License $license)
+ {
+ $license->update(['revoked_at' => null, 'revoke_reason' => null]);
+
+ return back()->with('status','Licence restored.');
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php b/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php
new file mode 100755
index 0000000..99484af
--- /dev/null
+++ b/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php
@@ -0,0 +1,59 @@
+with('assignee')
+ ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
+ ->latest()
+ ->paginate(50)
+ ->withQueryString();
+
+ return view('admin.support.index', compact('tickets'));
+ }
+
+ public function show(SupportTicket $ticket)
+ {
+ $ticket->load(['user', 'assignee']);
+ if (auth()->user()) {
+ auth()->user()->unreadNotifications()->get()
+ ->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
+ ->each->markAsRead();
+ }
+
+ return view('admin.support.show', [
+ 'ticket' => $ticket,
+ 'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
+ ]);
+ }
+
+ public function update(Request $request, SupportTicket $ticket): RedirectResponse
+ {
+ $data = $request->validate([
+ 'status' => ['required', 'in:new,open,waiting,resolved,closed'],
+ 'priority' => ['required', 'in:low,normal,high,urgent'],
+ 'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
+ ]);
+
+ if ($data['assigned_to'] ?? null) {
+ abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
+ }
+
+ $data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
+ ? ($ticket->resolved_at ?? now())
+ : null;
+ $ticket->update($data);
+
+ return back()->with('status', 'Support ticket updated.');
+ }
+}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php b/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php
old mode 100644
new mode 100755
index 4bd5b7d..d4e1aba
--- a/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php
@@ -1,4 +1,5 @@
validate([
- 'licenseKey'=>['required','string','max:128'],
- 'installationID'=>['required','uuid'],
- 'appVersion'=>['required','string','max:32'],
- 'platform'=>['required','string','max:32'],
+ 'licenseKey' => ['required', 'string', 'max:128'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
]);
- return DB::transaction(function () use ($data,$signer) {
- $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))->lockForUpdate()->first();
- if (!$license) throw ValidationException::withMessages(['licenseKey'=>'The licence key is not valid.']);
- if (!$license->is_usable) return response()->json(['message'=>'This licence is expired or has been revoked.'], 403);
+ return DB::transaction(function () use ($data, $signer) {
+ $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
+ ->lockForUpdate()
+ ->first();
- $activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['installationID']))->first();
- if ($activation && $activation->deactivated_at === null) {
- $activation->update(['last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
- } else {
- $activeCount = LicenseActivation::where('license_id',$license->id)->whereNull('deactivated_at')->count();
- if ($activeCount >= $license->activation_limit) return response()->json(['message'=>'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
- if ($activation) $activation->update(['deactivated_at'=>null,'activated_at'=>now(),'last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
- else LicenseActivation::create(['license_id'=>$license->id,'installation_id'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]);
+ if (! $license) {
+ throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
- return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]);
+
+ if (! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $installationID = strtolower($data['installationID']);
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', $installationID)
+ ->first();
+
+ if ($activation && $activation->deactivated_at === null) {
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ $activeCount = LicenseActivation::where('license_id', $license->id)
+ ->whereNull('deactivated_at')
+ ->count();
+
+ if ($activeCount >= $license->activation_limit) {
+ return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
+ }
+
+ if ($activation) {
+ $activation->update([
+ 'deactivated_at' => null,
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ LicenseActivation::create([
+ 'license_id' => $license->id,
+ 'installation_id' => $installationID,
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ ]);
+ }
+ }
+
+ return response()->json([
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
+ });
+ }
+
+ public function validateInstallation(
+ Request $request,
+ LicenseTokenVerifier $verifier,
+ LicenseSigner $signer,
+ ): JsonResponse {
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ return DB::transaction(function () use ($data, $payload, $signer) {
+ $license = License::whereKey((string) ($payload['licenseID'] ?? ''))
+ ->lockForUpdate()
+ ->first();
+
+ if (! $license || ! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', strtolower($data['installationID']))
+ ->first();
+
+ if (! $activation || $activation->deactivated_at !== null) {
+ return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
+ }
+
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+
+ return response()->json([
+ 'active' => true,
+ // Refreshing the signed token also applies subscription edition/expiry changes
+ // without requiring the user to reactivate manually.
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
});
}
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
{
- $data = $request->validate(['licenseToken'=>['required','string','max:8192'],'installationID'=>['required','uuid']]);
- try { $payload = $verifier->verify($data['licenseToken']); }
- catch (\Throwable) { return response()->json(['message'=>'The licence token is invalid.'], 400); }
- if (strcasecmp((string)($payload['installationID'] ?? ''), $data['installationID']) !== 0) return response()->json(['message'=>'The installation does not match this licence token.'], 400);
- LicenseActivation::where('license_id',(string)($payload['licenseID'] ?? ''))->where('installation_id',strtolower($data['installationID']))->whereNull('deactivated_at')->update(['deactivated_at'=>now(),'last_seen_at'=>now()]);
- return response()->json((object)[]);
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
+ ->where('installation_id', strtolower($data['installationID']))
+ ->whereNull('deactivated_at')
+ ->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
+
+ return response()->json((object) []);
}
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php b/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php
old mode 100644
new mode 100755
index 867d9ef..e4c8036
--- a/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php
@@ -1,8 +1,10 @@
getContent();
- $verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null};
- if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404);
- try { $verifier->verify($request,$raw); } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Invalid webhook signature.'],400); }
- try { $payload=json_decode($raw,true,flags:JSON_THROW_ON_ERROR); } catch (\Throwable) { return response()->json(['message'=>'Invalid JSON.'],400); }
- $externalID=(string)($provider==='paddle'?($payload['event_id']??''):($payload['meta']['event_name']??'').':'.($payload['data']['id']??''));
- $eventType=(string)($provider==='paddle'?($payload['event_type']??'unknown'):($payload['meta']['event_name']??'unknown'));
- if ($externalID==='') return response()->json(['message'=>'Webhook event has no stable identifier.'],400);
+ $raw = (string) $request->getContent();
+ $verifier = match ($provider) {
+ 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
+ };
+ if (! $verifier) {
+ return response()->json(['message' => 'Unknown webhook provider.'], 404);
+ }
+ try {
+ $verifier->verify($request, $raw);
+ } catch (\Throwable $e) {
+ report($e);
+
+ return response()->json(['message' => 'Invalid webhook signature.'], 400);
+ }
+ try {
+ $payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'Invalid JSON.'], 400);
+ }
+ $externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
+ $eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
+ if ($externalID === '') {
+ return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
+ }
try {
- DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){
- $event=LicenseWebhookEvent::firstOrCreate(['provider'=>$provider,'external_event_id'=>$externalID],['event_type'=>$eventType,'payload_sha256'=>hash('sha256',$raw),'payload'=>$payload]);
- if ($event->processed_at) return;
- if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
- try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
- catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
+ DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
+ $event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
+ if ($event->processed_at) {
+ return;
+ }
+ if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
+ throw new \RuntimeException('Webhook ID was reused with a different payload.');
+ }
+ try {
+ $provisioner->provision($provider, $payload);
+ $event->update(['processed_at' => now(), 'processing_error' => null]);
+ } catch (\Throwable $e) {
+ $event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
+ throw $e;
+ }
});
- } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
- return response()->json(['ok'=>true]);
+ } catch (\Throwable $e) {
+ report($e);
+
+ return response()->json(['message' => 'Webhook could not be processed.'], 500);
+ }
+
+ // Delivery is intentionally outside the webhook transaction. A temporary mail
+ // outage must not roll back an otherwise valid Paddle event. Pending outbox
+ // rows remain available for portal resend or a later webhook delivery attempt.
+ try {
+ $delivery->deliverPending(10);
+ } catch (\Throwable $e) {
+ report($e);
+ }
+
+ return response()->json(['ok' => true]);
}
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php
old mode 100644
new mode 100755
index 7622bce..d63878e
--- a/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php
@@ -1,14 +1,35 @@
validate(['email'=>['required','email'],'password'=>['required','string']]);
- if(!Auth::attempt(['email'=>strtolower($credentials['email']),'password'=>$credentials['password']], $request->boolean('remember'))){ return back()->withErrors(['email'=>'The supplied credentials are invalid.'])->onlyInput('email'); }
- $request->session()->regenerate(); return redirect()->intended(route('portal.index'));
- }
- public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('login'); }
+
+final class AuthenticatedSessionController extends Controller
+{
+ public function create()
+ {
+ return view('auth.login');
+ }
+
+ public function store(Request $request)
+ {
+ $credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
+ if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
+ return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
+ }
+ $request->session()->regenerate();
+
+ return redirect()->intended(route('portal.index'));
+ }
+
+ public function destroy(Request $request)
+ {
+ Auth::logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect()->route('login');
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php
old mode 100644
new mode 100755
index 1f020dc..2f38de1
--- a/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php
@@ -1,10 +1,31 @@
fulfill(); return redirect()->route('portal.index')->with('status','Email verified.'); }
- public function resend(Request $request){ if(!$request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification(); return back()->with('status','Verification link sent.'); }
+
+final class EmailVerificationController extends Controller
+{
+ public function notice()
+ {
+ return view('auth.verify-email');
+ }
+
+ public function verify(EmailVerificationRequest $request)
+ {
+ $request->fulfill();
+
+ return redirect()->route('portal.index')->with('status', 'Email verified.');
+ }
+
+ public function resend(Request $request)
+ {
+ if (! $request->user()->hasVerifiedEmail()) {
+ $request->user()->sendEmailVerificationNotification();
+ }
+
+return back()->with('status', 'Verification link sent.');
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php
old mode 100644
new mode 100755
index 7a416a9..4eb6694
--- a/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php
@@ -1,5 +1,7 @@
validate(['email'=>['required','email']]); $status=Password::sendResetLink(['email'=>strtolower($request->email)]); return $status===Password::RESET_LINK_SENT?back()->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
- public function resetForm(Request $request,string $token){ return view('auth.reset-password',['token'=>$token,'email'=>$request->query('email')]); }
- public function reset(Request $request){ $data=$request->validate(['token'=>['required'],'email'=>['required','email'],'password'=>['required','confirmed',PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); $status=Password::reset($data,function(User $user,string $password){$user->forceFill(['password'=>$password,'remember_token'=>Str::random(60)])->save();event(new PasswordReset($user));}); return $status===Password::PASSWORD_RESET?redirect()->route('login')->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
+
+final class PasswordController extends Controller
+{
+ public function requestForm()
+ {
+ return view('auth.forgot-password');
+ }
+
+ public function email(Request $request)
+ {
+ $request->validate(['email' => ['required', 'email']]);
+ $status = Password::sendResetLink(['email' => strtolower($request->email)]);
+
+ return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
+ }
+
+ public function resetForm(Request $request, string $token)
+ {
+ return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
+ }
+
+ public function reset(Request $request)
+ {
+ $data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
+ $status = Password::reset($data, function (User $user, string $password) {
+ $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
+ event(new PasswordReset($user));
+ });
+
+ return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php
old mode 100644
new mode 100755
index 035e0ef..3b5c073
--- a/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php
@@ -1,15 +1,29 @@
validate(['name'=>['required','string','max:120'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','confirmed',Password::min(12)->letters()->mixedCase()->numbers()]]);
- $user=User::create(['name'=>$data['name'],'email'=>strtolower($data['email']),'password'=>$data['password']]); event(new Registered($user)); Auth::login($user); $request->session()->regenerate(); return redirect()->route('verification.notice');
- }
+
+final class RegisteredUserController extends Controller
+{
+ public function create()
+ {
+ return view('auth.register');
+ }
+
+ public function store(Request $request)
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
+ $user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
+ event(new Registered($user));
+ Auth::login($user);
+ $request->session()->regenerate();
+
+ return redirect()->route('verification.notice');
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php b/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php
new file mode 100755
index 0000000..bdfff2b
--- /dev/null
+++ b/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php
@@ -0,0 +1,38 @@
+ ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
+ 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
+ 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
+ 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
+ ];
+
+ foreach ($plans as $plan) {
+ if (trim((string) $plan['price_id']) === '') {
+ throw new RuntimeException('One or more Paddle price IDs are not configured.');
+ }
+ }
+
+ return view('checkout.buy', [
+ 'plans' => $plans,
+ 'paddleToken' => $token,
+ 'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
+ ]);
+ }
+}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Controller.php b/rsync-studio-licensing/app/Http/Controllers/Controller.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php b/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php
old mode 100644
new mode 100755
index 429263d..d76d20f
--- a/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php
+++ b/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php
@@ -1,15 +1,142 @@
whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); }
- public function index(Request $request){ return view('portal.index',['licenses'=>$this->owned($request)->latest()->get()]); }
- public function show(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $license->load(['product','activations']); return view('portal.show',compact('license')); }
- public function deactivate(Request $request,License $license,int $activation){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $item=$license->activations()->whereKey($activation)->firstOrFail(); if(!$item->deactivated_at){$item->update(['deactivated_at'=>now()]);} return back()->with('status','Activation released.'); }
- public function resend(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $out=LicenseDeliveryOutbox::where('license_id',$license->id)->first(); if(!$out) return back()->withErrors(['licence'=>'This licence was issued manually and its full key is not recoverable. Contact support.']); $plain=Crypt::decryptString($out->encrypted_license_key); Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.",fn($m)=>$m->to($request->user()->email)->subject('Your Rsync Studio licence key')); $out->update(['delivered_at'=>now()]); return back()->with('status','Licence key sent to your verified email address.'); }
+use RuntimeException;
+
+final class PortalController extends Controller
+{
+ public function __construct(private PaddleClient $paddle) {}
+
+ private function owned(Request $request)
+ {
+ return License::with(['product', 'activations'])
+ ->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
+ }
+
+ public function index(Request $request)
+ {
+ $licenses = $this->owned($request)->latest()->get();
+ $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
+
+ return view('portal.index', compact('licenses', 'hasProfessional'));
+ }
+
+ public function show(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $license->load(['product', 'activations']);
+
+ return view('portal.show', compact('license'));
+ }
+
+ public function upgrade(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
+
+ $targetPriceID = $this->professionalPriceFor($license);
+ $targetProduct = LicenseProduct::query()
+ ->where('payment_provider', 'paddle')
+ ->where('external_price_id', $targetPriceID)
+ ->where('active', true)
+ ->firstOrFail();
+
+ try {
+ $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ // Apply the entitlement immediately. Paddle webhooks remain authoritative and
+ // will reconcile subscription status/current-period details afterwards.
+ $license->update([
+ 'license_product_id' => $targetProduct->id,
+ 'edition' => $targetProduct->edition,
+ 'activation_limit' => $targetProduct->activation_limit,
+ 'max_major_version' => $targetProduct->max_major_version,
+ 'external_price_id' => $targetPriceID,
+ 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
+ 'revoked_at' => null,
+ 'revoke_reason' => null,
+ ]);
+
+ return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
+ }
+
+ public function cancel(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
+
+ try {
+ $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ $license->update([
+ 'subscription_status' => 'canceled',
+ 'expires_at' => now(),
+ 'revoked_at' => now(),
+ 'revoke_reason' => 'Subscription canceled from licence portal',
+ 'cancel_scheduled_at' => null,
+ ]);
+
+ return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
+ }
+
+ public function deactivate(Request $request, License $license, int $activation)
+ {
+ $this->authorise($request, $license);
+ $item = $license->activations()->whereKey($activation)->firstOrFail();
+ if (! $item->deactivated_at) {
+ $item->update(['deactivated_at' => now()]);
+ }
+
+ return back()->with('status', 'Mac deactivated. Rsync Studio will detect this through the licensing API.');
+ }
+
+ public function resend(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
+ if (! $out) {
+ return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
+ }
+
+ $plain = Crypt::decryptString($out->encrypted_license_key);
+ Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your Rsync Studio licence key'));
+ $out->update(['delivered_at' => now()]);
+
+ return back()->with('status', 'Licence key sent to your verified email address.');
+ }
+
+ private function authorise(Request $request, License $license): void
+ {
+ abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
+ }
+
+ private function professionalPriceFor(License $license): string
+ {
+ $paddle = config('rsync-studio.paddle');
+ $current = (string) $license->external_price_id;
+
+ if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
+ return (string) ($paddle['professional_annual_price_id'] ?? '');
+ }
+
+ return (string) ($paddle['professional_monthly_price_id'] ?? '');
+ }
}
diff --git a/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php b/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php
new file mode 100755
index 0000000..13e9bec
--- /dev/null
+++ b/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php
@@ -0,0 +1,92 @@
+ [
+ 'technical' => 'Technical support',
+ 'licensing' => 'Licence & activation',
+ 'billing' => 'Billing & subscription',
+ 'feature' => 'Feature request',
+ 'privacy' => 'Privacy / data request',
+ 'general' => 'General enquiry',
+ ],
+ ]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ // Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
+ if (filled($request->input('company_website'))) {
+ return back()->with('status', 'Your support request has been received.');
+ }
+
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:120'],
+ 'email' => ['required', 'email:rfc', 'max:254'],
+ 'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
+ 'subject' => ['required', 'string', 'max:180'],
+ 'message' => ['required', 'string', 'min:10', 'max:10000'],
+ 'privacy_notice' => ['accepted'],
+ ]);
+
+ $ticket = SupportTicket::create([
+ 'user_id' => $request->user()?->id,
+ 'reference' => $this->reference(),
+ 'name' => $data['name'],
+ 'email' => strtolower($data['email']),
+ 'category' => $data['category'],
+ 'subject' => $data['subject'],
+ 'message' => $data['message'],
+ 'privacy_notice_accepted_at' => now(),
+ ]);
+
+ $this->notifyTeam($ticket);
+
+ Mail::raw(
+ "We received your Rsync Studio support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
+ fn ($mail) => $mail->to($ticket->email)->subject("Rsync Studio support request {$ticket->reference}")
+ );
+
+ return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
+ }
+
+ private function notifyTeam(SupportTicket $ticket): void
+ {
+ if (config('rsync-studio.support.notify_admin_users', true)) {
+ User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
+ }
+
+ $emails = collect(config('rsync-studio.support.alert_emails', []))
+ ->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
+ ->unique()
+ ->values();
+
+ if ($emails->isNotEmpty()) {
+ Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
+ }
+ }
+
+ private function reference(): string
+ {
+ do {
+ $reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
+ } while (SupportTicket::where('reference', $reference)->exists());
+
+ return $reference;
+ }
+}
diff --git a/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php b/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php
old mode 100644
new mode 100755
index 6de2fcd..e7cd3a6
--- a/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php
+++ b/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php
@@ -1,11 +1,17 @@
user()?->is_admin === true, 403);
+
return $next($request);
}
}
diff --git a/rsync-studio-licensing/app/Models/License.php b/rsync-studio-licensing/app/Models/License.php
old mode 100644
new mode 100755
index e42d8b0..c3dcc56
--- a/rsync-studio-licensing/app/Models/License.php
+++ b/rsync-studio-licensing/app/Models/License.php
@@ -1,4 +1,5 @@
'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer'];
- public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
- public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); }
- public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
+
+ protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
+
+ protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(LicenseProduct::class, 'license_product_id');
+ }
+
+ public function activations(): HasMany
+ {
+ return $this->hasMany(LicenseActivation::class);
+ }
+
+ public function getIsUsableAttribute(): bool
+ {
+ return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
+ }
}
diff --git a/rsync-studio-licensing/app/Models/LicenseActivation.php b/rsync-studio-licensing/app/Models/LicenseActivation.php
old mode 100644
new mode 100755
index 8139a60..8562d4b
--- a/rsync-studio-licensing/app/Models/LicenseActivation.php
+++ b/rsync-studio-licensing/app/Models/LicenseActivation.php
@@ -1,9 +1,14 @@
'datetime','last_seen_at'=>'datetime','deactivated_at'=>'datetime'];
+
+ protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
+
+ protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
diff --git a/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php b/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php
old mode 100644
new mode 100755
index 63ae8ce..e0af182
--- a/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php
+++ b/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php
@@ -1,9 +1,14 @@
'datetime'];
+
+ protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
+
+ protected $casts = ['delivered_at' => 'datetime'];
}
diff --git a/rsync-studio-licensing/app/Models/LicenseProduct.php b/rsync-studio-licensing/app/Models/LicenseProduct.php
old mode 100644
new mode 100755
index 28cf89c..85ac9f5
--- a/rsync-studio-licensing/app/Models/LicenseProduct.php
+++ b/rsync-studio-licensing/app/Models/LicenseProduct.php
@@ -1,10 +1,12 @@
'boolean','activation_limit'=>'integer','max_major_version'=>'integer'];
+ protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
+
+ protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
diff --git a/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php b/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php
old mode 100644
new mode 100755
index ac1c2a2..38fea30
--- a/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php
+++ b/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php
@@ -1,8 +1,12 @@
'array','processed_at'=>'datetime'];
+ protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
+
+ protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
diff --git a/rsync-studio-licensing/app/Models/SupportTicket.php b/rsync-studio-licensing/app/Models/SupportTicket.php
new file mode 100755
index 0000000..6fa73ef
--- /dev/null
+++ b/rsync-studio-licensing/app/Models/SupportTicket.php
@@ -0,0 +1,46 @@
+ 'datetime',
+ 'resolved_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function assignee(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'assigned_to');
+ }
+
+ public function prunable(): Builder
+ {
+ $days = max(30, (int) config('rsync-studio.privacy.support_retention_days', 730));
+
+ return static::query()
+ ->whereNotNull('resolved_at')
+ ->where('resolved_at', '<=', now()->subDays($days));
+ }
+}
diff --git a/rsync-studio-licensing/app/Models/User.php b/rsync-studio-licensing/app/Models/User.php
old mode 100644
new mode 100755
index 2c45b19..ff6872c
--- a/rsync-studio-licensing/app/Models/User.php
+++ b/rsync-studio-licensing/app/Models/User.php
@@ -1,4 +1,5 @@
'datetime','password'=>'hashed','is_admin'=>'boolean']; }
+
+ protected $fillable = ['name', 'email', 'password', 'is_admin'];
+
+ protected $hidden = ['password', 'remember_token'];
+
+ protected function casts(): array
+ {
+ return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
+ }
}
diff --git a/rsync-studio-licensing/app/Notifications/NewSupportTicket.php b/rsync-studio-licensing/app/Notifications/NewSupportTicket.php
new file mode 100755
index 0000000..a1528ae
--- /dev/null
+++ b/rsync-studio-licensing/app/Notifications/NewSupportTicket.php
@@ -0,0 +1,43 @@
+subject("New Rsync Studio support request {$this->ticket->reference}")
+ ->greeting('New support request')
+ ->line("{$this->ticket->name} <{$this->ticket->email}>")
+ ->line("Category: {$this->ticket->category}")
+ ->line("Subject: {$this->ticket->subject}")
+ ->line($this->ticket->message)
+ ->action('Open support ticket', route('admin.support.show', $this->ticket));
+ }
+
+ public function toArray(object $notifiable): array
+ {
+ return [
+ 'support_ticket_id' => $this->ticket->id,
+ 'reference' => $this->ticket->reference,
+ 'subject' => $this->ticket->subject,
+ 'email' => $this->ticket->email,
+ ];
+ }
+}
diff --git a/rsync-studio-licensing/app/Providers/RsyncStudioLicensingServiceProvider.php b/rsync-studio-licensing/app/Providers/RsyncStudioLicensingServiceProvider.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php b/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php
new file mode 100755
index 0000000..94e804c
--- /dev/null
+++ b/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php
@@ -0,0 +1,40 @@
+whereNull('delivered_at')
+ ->whereNotNull('recipient_email')
+ ->where('recipient_email', '!=', '')
+ ->oldest()
+ ->limit(max(1, min($limit, 100)))
+ ->get();
+
+ foreach ($pending as $outbox) {
+ $plain = Crypt::decryptString($outbox->encrypted_license_key);
+ $recipient = strtolower(trim((string) $outbox->recipient_email));
+ if ($recipient === '') {
+ continue;
+ }
+
+ Mail::raw(
+ "Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
+ fn ($message) => $message->to($recipient)->subject('Your Rsync Studio licence key')
+ );
+
+ $outbox->update(['delivered_at' => now()]);
+ $delivered++;
+ }
+
+ return $delivered;
+ }
+}
diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php b/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php
old mode 100644
new mode 100755
index 6702788..ec51713
--- a/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php
+++ b/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php
@@ -1,41 +1,45 @@
where('external_order_id',$orderID)->first();
- if ($existing) return [$existing, null];
+ $existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
+ if ($existing) {
+ return [$existing, null];
+ }
}
$plain = LicenseKey::generate();
$license = License::create([
- 'license_product_id'=>$product->id,
- 'license_key_hash'=>LicenseKey::hash($plain),
- 'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
- 'customer_name'=>$name,
- 'customer_email'=>$email ? strtolower(trim($email)) : null,
- 'edition'=>$product->edition,
- 'activation_limit'=>$product->activation_limit,
- 'max_major_version'=>$product->max_major_version,
- 'payment_provider'=>$provider,
- 'external_order_id'=>$orderID,
- 'external_customer_id'=>$customerID,
+ 'license_product_id' => $product->id,
+ 'license_key_hash' => LicenseKey::hash($plain),
+ 'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
+ 'customer_name' => $name,
+ 'customer_email' => $email ? strtolower(trim($email)) : null,
+ 'edition' => $product->edition,
+ 'activation_limit' => $product->activation_limit,
+ 'max_major_version' => $product->max_major_version,
+ 'payment_provider' => $provider,
+ 'external_order_id' => $orderID,
+ 'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
- ['license_id'=>$license->id],
- ['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
+ ['license_id' => $license->id],
+ ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
+
return [$license, $plain];
});
}
diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php b/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php
old mode 100644
new mode 100755
index c1104ae..3e42361
--- a/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php
+++ b/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php
@@ -1,4 +1,5 @@
privateSeed());
+
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('rsync-studio.private_key_path');
- if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ if (! $path || ! is_file($path)) {
+ throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ }
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
- if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
+ throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ }
+
return $decoded;
}
diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php b/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php
old mode 100644
new mode 100755
index b461cbf..90b7b3b
--- a/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php
+++ b/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php
@@ -1,4 +1,5 @@
decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true);
- if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
- if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
+ if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
+ throw new RuntimeException('Server licence public key is invalid.');
+ }
+ if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
+ throw new RuntimeException('Licence token signature is invalid.');
+ }
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
+
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
+
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
- if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
+ if ($out === false) {
+ throw new RuntimeException('Invalid Base64URL value.');
+ }
+
return $out;
}
}
diff --git a/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php
old mode 100644
new mode 100755
index d94d56a..3d7a49b
--- a/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php
+++ b/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php
@@ -1,4 +1,5 @@
header('X-Signature');
- if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
- $expected = hash_hmac('sha256',$rawBody,$secret);
- if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
+ if ($secret === '' || $signature === '') {
+ throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
+ }
+ $expected = hash_hmac('sha256', $rawBody, $secret);
+ if (! hash_equals($expected, $signature)) {
+ throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
+ }
}
}
diff --git a/rsync-studio-licensing/app/Services/Payments/PaddleClient.php b/rsync-studio-licensing/app/Services/Payments/PaddleClient.php
new file mode 100755
index 0000000..6dd5d35
--- /dev/null
+++ b/rsync-studio-licensing/app/Services/Payments/PaddleClient.php
@@ -0,0 +1,82 @@
+request()->get('/customers/'.$customerID);
+
+ if (! $response->successful()) {
+ return null;
+ }
+ $data = $response->json('data');
+
+ return is_array($data) ? $data : null;
+ }
+
+ public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array
+ {
+ $this->requireWritableConfiguration();
+
+ $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [
+ 'proration_billing_mode' => 'prorated_immediately',
+ 'items' => [[
+ 'price_id' => $priceID,
+ 'quantity' => 1,
+ ]],
+ ]);
+
+ return $this->subscriptionData($response, 'Paddle could not update the subscription.');
+ }
+
+ public function cancelSubscriptionImmediately(string $subscriptionID): array
+ {
+ $this->requireWritableConfiguration();
+
+ $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [
+ 'effective_from' => 'immediately',
+ ]);
+
+ return $this->subscriptionData($response, 'Paddle could not cancel the subscription.');
+ }
+
+ private function request()
+ {
+ return Http::baseUrl((string) config('rsync-studio.paddle.api_url'))
+ ->withToken((string) config('rsync-studio.paddle.api_key'))
+ ->acceptJson()
+ ->timeout(15);
+ }
+
+ private function requireWritableConfiguration(): void
+ {
+ if (trim((string) config('rsync-studio.paddle.api_key')) === '') {
+ throw new RuntimeException('Paddle subscription management is not configured.');
+ }
+ }
+
+ private function subscriptionData(Response $response, string $fallback): array
+ {
+ if (! $response->successful()) {
+ $detail = trim((string) ($response->json('error.detail') ?? ''));
+ throw new RuntimeException($detail !== '' ? $detail : $fallback);
+ }
+
+ $data = $response->json('data');
+ if (! is_array($data)) {
+ throw new RuntimeException($fallback);
+ }
+
+ return $data;
+ }
+}
diff --git a/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php
old mode 100644
new mode 100755
index bce9424..813827d
--- a/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php
+++ b/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php
@@ -1,4 +1,5 @@
header('Paddle-Signature');
- if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
+ if ($secret === '' || $header === '') {
+ throw new RuntimeException('Paddle webhook verification is not configured.');
+ }
$parts = [];
- foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
- $ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
+ foreach (explode(';', $header) as $part) {
+ [$k,$v] = array_pad(explode('=', trim($part), 2), 2, null);
+ if ($k && $v) {
+ $parts[$k][] = $v;
+ }
+ }
+ $ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
- $tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
- if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
+ $tolerance = max(1, (int) config('rsync-studio.paddle.timestamp_tolerance', 300));
+ if (! $ts || abs(time() - $ts) > $tolerance) {
+ throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
+ }
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
- foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
+ foreach ($signatures as $signature) {
+ if (hash_equals($expected, $signature)) {
+ return;
+ }
+ }
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
diff --git a/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php b/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php
old mode 100644
new mode 100755
index 7cb0c4c..2cc9680
--- a/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php
+++ b/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php
@@ -1,47 +1,246 @@
$this->fromPaddle($event),
- 'lemon-squeezy' => $this->fromLemonSqueezy($event),
- default => throw new RuntimeException('Unsupported payment provider.'),
- };
- if ($normalized['action'] === 'ignore') return;
- if ($normalized['action'] === 'revoke') {
- License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
+ if ($provider === 'paddle') {
+ $this->provisionPaddle($event);
+
return;
}
- $product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
- if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
- $this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
+
+ if ($provider === 'lemon-squeezy') {
+ $this->provisionLemonSqueezy($event);
+
+ return;
+ }
+
+ throw new RuntimeException('Unsupported payment provider.');
}
- private function fromPaddle(array $e): array
+ private function provisionPaddle(array $event): void
{
- $type=(string)($e['event_type']??''); $d=$e['data']??[];
- if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
- if ($type!=='transaction.completed') return ['action'=>'ignore'];
- $item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
- return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
+ $type = (string) ($event['event_type'] ?? '');
+ $data = $event['data'] ?? [];
+
+ if (str_starts_with($type, 'subscription.')) {
+ $this->syncPaddleSubscription($type, $data);
+
+ return;
+ }
+
+ if ($type === 'transaction.completed') {
+ $this->syncPaddleTransaction($data);
+
+ return;
+ }
+
+ if ($type === 'transaction.refunded') {
+ $transactionID = (string) ($data['id'] ?? '');
+ if ($transactionID !== '') {
+ License::where('payment_provider', 'paddle')
+ ->where('external_order_id', $transactionID)
+ ->whereNull('external_subscription_id')
+ ->whereNull('revoked_at')
+ ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']);
+ }
+ }
}
- private function fromLemonSqueezy(array $e): array
+ private function syncPaddleSubscription(string $type, array $data): void
{
- $type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
- $order=(string)($e['data']['id']??($a['order_id']??''));
- if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
- if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
- return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
+ $subscriptionID = (string) ($data['id'] ?? '');
+ if ($subscriptionID === '') {
+ throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
+ }
+
+ $status = (string) ($data['status'] ?? '');
+ $item = $this->firstRecurringItem($data['items'] ?? []);
+ $price = $item['price'] ?? [];
+ $priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
+ $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
+
+ $license = License::where('payment_provider', 'paddle')
+ ->where('external_subscription_id', $subscriptionID)
+ ->first();
+
+ if (! $license) {
+ $product = $this->mappedProduct('paddle', $priceID, $productID);
+ $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
+ $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
+ [$license] = $this->issuer->issue(
+ $product,
+ $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
+ $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
+ 'paddle',
+ $this->nullableString($data['transaction_id'] ?? null),
+ $this->nullableString($data['customer_id'] ?? null),
+ true,
+ );
+ $license->external_subscription_id = $subscriptionID;
+ } elseif ($priceID !== '' && $priceID !== $license->external_price_id) {
+ // Upgrades/downgrades change the entitlement attached to the existing key.
+ $product = $this->mappedProduct('paddle', $priceID, $productID);
+ $license->license_product_id = $product->id;
+ $license->edition = $product->edition;
+ $license->activation_limit = $product->activation_limit;
+ $license->max_major_version = $product->max_major_version;
+ }
+
+ $periodEnd = $data['current_billing_period']['ends_at'] ?? null;
+ $scheduled = $data['scheduled_change'] ?? null;
+ $cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
+ ? ($scheduled['effective_at'] ?? null)
+ : null;
+
+ $license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id;
+ $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id;
+ $license->subscription_status = $status !== '' ? $status : $license->subscription_status;
+ $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at;
+ $license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null;
+
+ if (in_array($status, ['active', 'trialing'], true)) {
+ $license->revoked_at = null;
+ $license->revoke_reason = null;
+ // Trialing/active subscriptions remain valid through the current period.
+ $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
+ } elseif ($status === 'past_due') {
+ // Do not instantly kill a licence on the first failed renewal. Preserve the
+ // period Paddle says is current; a later canceled event ends entitlement.
+ $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7));
+ } elseif (in_array($status, ['canceled', 'paused'], true)) {
+ $license->expires_at = now();
+ $license->revoked_at = now();
+ $license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
+ }
+
+ $license->save();
+ }
+
+ private function syncPaddleTransaction(array $data): void
+ {
+ $subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
+ $transactionID = (string) ($data['id'] ?? '');
+
+ // Recurring purchases are provisioned from subscription events because those
+ // events contain the authoritative trial/status/current-period state.
+ if ($subscriptionID) {
+ License::where('payment_provider', 'paddle')
+ ->where('external_subscription_id', $subscriptionID)
+ ->whereNull('external_order_id')
+ ->update(['external_order_id' => $transactionID ?: null]);
+
+ return;
+ }
+
+ // Keep support for any future one-time Paddle licence product.
+ $item = $data['items'][0] ?? [];
+ $price = $item['price'] ?? [];
+ $priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
+ $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
+ $product = $this->mappedProduct('paddle', $priceID, $productID);
+ $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
+ $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
+
+ $this->issuer->issue(
+ $product,
+ $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
+ $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
+ 'paddle',
+ $transactionID,
+ $this->nullableString($data['customer_id'] ?? null),
+ true,
+ );
+ }
+
+ private function provisionLemonSqueezy(array $event): void
+ {
+ $type = (string) ($event['meta']['event_name'] ?? '');
+ $attributes = $event['data']['attributes'] ?? [];
+ $order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
+
+ if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
+ License::where('payment_provider', 'lemon-squeezy')
+ ->where('external_order_id', $order)
+ ->whereNull('revoked_at')
+ ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
+
+ return;
+ }
+
+ if (! in_array($type, ['order_created', 'subscription_created'], true)) {
+ return;
+ }
+
+ $product = $this->mappedProduct(
+ 'lemon-squeezy',
+ (string) ($attributes['variant_id'] ?? ''),
+ (string) ($attributes['product_id'] ?? ''),
+ );
+
+ $this->issuer->issue(
+ $product,
+ $this->nullableString($attributes['user_name'] ?? null),
+ $this->nullableString($attributes['user_email'] ?? null),
+ 'lemon-squeezy',
+ $order,
+ $this->nullableString($attributes['customer_id'] ?? null),
+ true,
+ );
+ }
+
+ private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct
+ {
+ // Price IDs are the authoritative mapping because monthly and annual prices
+ // intentionally share the same Paddle product ID.
+ if ($priceID !== '') {
+ $product = LicenseProduct::where('payment_provider', $provider)
+ ->where('external_price_id', $priceID)
+ ->where('active', true)
+ ->first();
+ } else {
+ $product = LicenseProduct::where('payment_provider', $provider)
+ ->where('external_product_id', $productID)
+ ->where('active', true)
+ ->first();
+ }
+
+ if (! $product) {
+ throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
+ }
+
+ return $product;
+ }
+
+ private function firstRecurringItem(array $items): array
+ {
+ foreach ($items as $item) {
+ if (($item['recurring'] ?? true) === true) {
+ return $item;
+ }
+ }
+
+ return $items[0] ?? [];
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ if (! is_scalar($value)) {
+ return null;
+ }
+ $value = trim((string) $value);
+
+ return $value === '' ? null : $value;
}
}
diff --git a/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php
old mode 100644
new mode 100755
index 5f31a79..984c28e
--- a/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php
+++ b/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php
@@ -1,4 +1,10 @@
=0.6.2)"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Predis\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Till Krüss",
+ "homepage": "https://till.im",
+ "role": "Maintainer"
+ }
+ ],
+ "description": "A flexible and feature-complete Redis/Valkey client for PHP.",
+ "homepage": "http://github.com/predis/predis",
+ "keywords": [
+ "nosql",
+ "predis",
+ "redis"
+ ],
+ "support": {
+ "issues": "https://github.com/predis/predis/issues",
+ "source": "https://github.com/predis/predis/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/tillkruss",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-14T23:07:56+00:00"
+ },
{
"name": "psr/clock",
"version": "1.0.0",
diff --git a/rsync-studio-licensing/composer.stage4.json b/rsync-studio-licensing/composer.stage4.json
deleted file mode 100644
index 9442786..0000000
--- a/rsync-studio-licensing/composer.stage4.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "require": {
- "php": "^8.3",
- "ext-sodium": "*",
- "laravel/framework": "^13.0"
- }
-}
diff --git a/rsync-studio-licensing/config/rsync-studio.php b/rsync-studio-licensing/config/rsync-studio.php
old mode 100644
new mode 100755
index d500f47..54c5b73
--- a/rsync-studio-licensing/config/rsync-studio.php
+++ b/rsync-studio-licensing/config/rsync-studio.php
@@ -10,9 +10,35 @@ return [
'purchase_url' => env('RSYNC_STUDIO_PURCHASE_URL'),
'portal_url' => env('RSYNC_STUDIO_PORTAL_URL'),
'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'),
+
+ 'legal' => [
+ 'controller_name' => env('RSYNC_STUDIO_LEGAL_NAME', 'Rsync Studio'),
+ 'privacy_email' => env('RSYNC_STUDIO_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')),
+ 'support_email' => env('RSYNC_STUDIO_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')),
+ 'country' => env('RSYNC_STUDIO_LEGAL_COUNTRY', 'United Kingdom'),
+ ],
+ 'privacy' => [
+ 'policy_version' => env('RSYNC_STUDIO_PRIVACY_VERSION', '2026-09-09'),
+ 'cookie_version' => env('RSYNC_STUDIO_COOKIE_VERSION', '1'),
+ 'support_retention_days' => (int) env('RSYNC_STUDIO_SUPPORT_RETENTION_DAYS', 730),
+ ],
+ 'support' => [
+ 'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('RSYNC_STUDIO_SUPPORT_ALERT_EMAILS', ''))))),
+ 'notify_admin_users' => filter_var(env('RSYNC_STUDIO_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
+ ],
'paddle' => [
+ 'api_key' => env('PADDLE_API_KEY'),
+ 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
+ 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
+ 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
+ 'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'),
+ 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
+ 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
+ 'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'),
+ 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
+ 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
],
'lemon_squeezy' => [
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
diff --git a/rsync-studio-licensing/database/database.sqlite b/rsync-studio-licensing/database/database.sqlite
old mode 100644
new mode 100755
index 63f9d49..379c23d
Binary files a/rsync-studio-licensing/database/database.sqlite and b/rsync-studio-licensing/database/database.sqlite differ
diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php b/rsync-studio-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php b/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php
old mode 100644
new mode 100755
index 11f7b7e..babc7a3
--- a/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php
+++ b/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_200758_create_cache_table.php b/rsync-studio-licensing/database/migrations/2026_09_08_200758_create_cache_table.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_205000_create_rsync_studio_auth_tables.php b/rsync-studio-licensing/database/migrations/2026_09_08_205000_create_rsync_studio_auth_tables.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_210000_add_rsync_studio_portal_fields.php b/rsync-studio-licensing/database/migrations/2026_09_08_210000_add_rsync_studio_portal_fields.php
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php
new file mode 100755
index 0000000..ef60b76
--- /dev/null
+++ b/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php
@@ -0,0 +1,34 @@
+string('external_subscription_id')->nullable()->after('external_customer_id');
+ $table->string('external_price_id')->nullable()->after('external_subscription_id');
+ $table->string('subscription_status')->nullable()->after('external_price_id');
+ $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status');
+ $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at');
+ $table->unique(['payment_provider', 'external_subscription_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('licenses', function (Blueprint $table) {
+ $table->dropUnique(['payment_provider', 'external_subscription_id']);
+ $table->dropColumn([
+ 'external_subscription_id',
+ 'external_price_id',
+ 'subscription_status',
+ 'current_period_ends_at',
+ 'cancel_scheduled_at',
+ ]);
+ });
+ }
+};
diff --git a/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php b/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php
new file mode 100755
index 0000000..a6223e3
--- /dev/null
+++ b/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php
@@ -0,0 +1,47 @@
+id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('reference', 24)->unique();
+ $table->string('name', 120);
+ $table->string('email', 254)->index();
+ $table->string('category', 40)->default('general');
+ $table->string('subject', 180);
+ $table->text('message');
+ $table->string('status', 20)->default('new')->index();
+ $table->string('priority', 20)->default('normal');
+ $table->timestamp('privacy_notice_accepted_at');
+ $table->timestamp('resolved_at')->nullable()->index();
+ $table->timestamps();
+ });
+ }
+
+ if (! Schema::hasTable('notifications')) {
+ Schema::create('notifications', function (Blueprint $table): void {
+ $table->uuid('id')->primary();
+ $table->string('type');
+ $table->morphs('notifiable');
+ $table->text('data');
+ $table->timestamp('read_at')->nullable();
+ $table->timestamps();
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('support_tickets');
+ Schema::dropIfExists('notifications');
+ }
+};
diff --git a/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php b/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php
new file mode 100755
index 0000000..23b0091
--- /dev/null
+++ b/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php
@@ -0,0 +1,29 @@
+ 'personal-monthly', 'name' => 'Rsync Studio Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_monthly_price_id')],
+ ['code' => 'personal-annual', 'name' => 'Rsync Studio Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_annual_price_id')],
+ ['code' => 'professional-monthly', 'name' => 'Rsync Studio Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_monthly_price_id')],
+ ['code' => 'professional-annual', 'name' => 'Rsync Studio Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_annual_price_id')],
+ ];
+
+ foreach ($products as $data) {
+ if (! $data['external_product_id'] || ! $data['external_price_id']) {
+ continue;
+ }
+ LicenseProduct::updateOrCreate(
+ ['code' => $data['code']],
+ $data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true]
+ );
+ }
+ }
+}
diff --git a/rsync-studio-licensing/deploy.php b/rsync-studio-licensing/deploy.php
old mode 100644
new mode 100755
index ea60122..ebbfe7f
--- a/rsync-studio-licensing/deploy.php
+++ b/rsync-studio-licensing/deploy.php
@@ -9,7 +9,7 @@ set('writable_use_sudo', true);
set('clear_use_sudo', true);
set('cleanup_use_sudo', true);
set('keep_releases', 1);
-set('repository', 'git@git.absdev.net:admin/RSync-Studio.git');
+set('repository', 'git@git.absdev.net:mag/rsync_licensing_server.git');
set('node_version', '22.12.0');
set('allow_anonymous_stats', false);
@@ -18,8 +18,8 @@ add('shared_dirs', ['storage']);
add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
-host('main')
- ->set('hostname', 'rsyncstudio.absdev.net')
+host('prod')
+ ->set('hostname', 'rsyncs.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('deploy_path', '/var/www/rsyncstudio');
@@ -28,7 +28,7 @@ host('stage')
->set('hostname', 'pegasus.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'stage')
- ->set('deploy_path', '/var/www/rsyncstudio');
+ ->set('deploy_path', '/var/www/rsyncstudio_dev');
task('build', function (): void {
run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm ci --no-audit --no-fund');
@@ -50,7 +50,21 @@ task('artisan:clear', function (): void {
task('services:restart', function (): void {
run('sudo -n nginx -t');
run('sudo -n systemctl reload nginx');
- run('sudo -n systemctl restart php8.3-fpm');
+ $phpFpm = run(<<<'BASH'
+ if systemctl list-unit-files php8.5-fpm.service --no-legend | grep -q '^php8.5-fpm.service'; then
+ echo php8.5-fpm
+ elif systemctl list-unit-files php8.3-fpm.service --no-legend | grep -q '^php8.3-fpm.service'; then
+ echo php8.3-fpm
+ else
+ exit 1
+ fi
+ BASH);
+ $phpFpm = trim($phpFpm);
+ if ($phpFpm === '') {
+ throw new \RuntimeException('No supported PHP-FPM service found (8.5 or 8.3).');
+ }
+ writeln("Restarting {$phpFpm}...");
+ run("sudo -n systemctl restart {$phpFpm}");
});
after('deploy:update_code', 'deploy:write_revision_file');
diff --git a/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md b/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md
new file mode 100755
index 0000000..bb6826d
--- /dev/null
+++ b/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md
@@ -0,0 +1,71 @@
+# Paddle subscription integration
+
+## Sandbox configuration
+
+Keep the Paddle API key and webhook secret server-side only. Do not put either value in the macOS application or commit them to Git.
+
+Required environment variables:
+
+```dotenv
+PADDLE_API_KEY=pdl_sdbx_apikey_...
+PADDLE_API_URL=https://sandbox-api.paddle.com
+PADDLE_WEBHOOK_SECRET=...
+
+PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0
+PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy
+PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh
+PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n
+PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8
+PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52
+```
+
+Then run:
+
+```bash
+php artisan migrate
+php artisan db:seed --class=Database\\Seeders\\PaddleCatalogSeeder
+php artisan config:clear
+```
+
+The seeder maps the four Paddle price IDs to licensing entitlements. Personal permits one active Mac; Professional permits three.
+
+## Paddle notification destination
+
+Configure the sandbox notification destination to POST to:
+
+`https://YOUR-HOST/api/v1/webhooks/paddle`
+
+Copy the destination endpoint secret into `PADDLE_WEBHOOK_SECRET`.
+
+Subscribe to at least:
+
+- `subscription.created`
+- `subscription.updated`
+- `subscription.activated`
+- `subscription.trialing`
+- `subscription.canceled`
+- `subscription.paused`
+- `subscription.resumed`
+- `subscription.past_due`
+- `transaction.completed`
+- `transaction.refunded`
+
+## Entitlement behavior
+
+- Trialing and active subscriptions are usable.
+- A scheduled end-of-period cancellation stays usable until Paddle changes the subscription to canceled.
+- Upgrades/downgrades update the entitlement on the existing licence key.
+- Canceled or paused subscriptions revoke the licence.
+- Past-due subscriptions retain access through Paddle's current billing period rather than being revoked on the first failed payment.
+- Paddle customer details are fetched server-side when the webhook does not carry customer email/name in custom data.
+
+## Important
+
+The API key is required only on the licensing server. Use a sandbox key with the sandbox API URL. Live Paddle products/prices have different IDs and should be configured separately when moving to production.
+
+
+## Hosted sandbox checkout
+
+The licensing site exposes `/buy`, which initializes Paddle.js with `PADDLE_CLIENT_SIDE_TOKEN` and opens an overlay checkout for the four configured price IDs. Set `PADDLE_ENVIRONMENT=sandbox` while testing and use a `test_...` client-side token. The token is intentionally browser-visible; never use `PADDLE_API_KEY` in frontend code.
+
+After a subscription webhook provisions a licence, the webhook controller makes a best-effort attempt to deliver pending licence keys by email. Delivery failures do not roll back the verified Paddle event; the encrypted outbox remains available for portal resend.
diff --git a/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md b/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md
new file mode 100755
index 0000000..bdd65ec
--- /dev/null
+++ b/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md
@@ -0,0 +1,24 @@
+# Rsync Studio launch frontend
+
+The public `/` page is now a product-launch landing page using the same shared Laravel layout as checkout, support, authentication and the licence portal.
+
+## Favicon/device assets
+
+The website uses the existing Rsync Studio app icon as the web brand source and includes:
+
+- multi-resolution `public/favicon.ico`
+- 16x16, 32x32 and 48x48 browser favicons
+- 180x180 Apple touch icon
+- 192x192 and 512x512 Android/PWA icons
+- Windows 150x150 tile icon and `browserconfig.xml`
+- Safari pinned-tab mask icon
+- `site.webmanifest`
+- Open Graph/Twitter image metadata
+
+The shared `resources/views/layouts/app.blade.php` loads these assets on every website page.
+
+## Launch page
+
+`resources/views/portal/welcome.blade.php` contains the public product page: hero, native app preview, features, workflow, security positioning, Personal/Professional plan summary and trial calls-to-action.
+
+Non-production environments automatically emit `noindex,nofollow`; production emits normal index/follow metadata.
diff --git a/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env b/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env
new file mode 100755
index 0000000..87b3c5f
--- /dev/null
+++ b/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env
@@ -0,0 +1,7 @@
+PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0
+PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy
+PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh
+
+PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n
+PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8
+PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52
diff --git a/rsync-studio-licensing/package-lock.json b/rsync-studio-licensing/package-lock.json
new file mode 100755
index 0000000..b211042
--- /dev/null
+++ b/rsync-studio-licensing/package-lock.json
@@ -0,0 +1,2354 @@
+{
+ "name": "rsync-studio-licensing",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "concurrently": "^10.0.3",
+ "laravel-vite-plugin": "^3.1",
+ "tailwindcss": "^4.0.0",
+ "vite": "^8.0.0"
+ },
+ "optionalDependencies": {
+ "@laravel/multiplex": "^0.4.1"
+ }
+ },
+ "node_modules/@alcalzone/ansi-tokenize": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz",
+ "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@laravel/multiplex": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@laravel/multiplex/-/multiplex-0.4.3.tgz",
+ "integrity": "sha512-jJTZGgKl6sK0ETYmqiPqeAwmErvqjxgDRLfzqsmqiWiupc5dfjaoVVrrdXZwb6eGylSg99WwHLibCPJs8F0bGw==",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "commander": "^15.0.0",
+ "ink": "^7.1.0",
+ "react": "^19.2.7",
+ "string-width": "^8.2.2",
+ "wrap-ansi": "^10.0.0"
+ },
+ "bin": {
+ "multiplex": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=22.13.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
+ "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
+ "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz",
+ "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz",
+ "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz",
+ "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz",
+ "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz",
+ "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz",
+ "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz",
+ "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz",
+ "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz",
+ "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz",
+ "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz",
+ "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz",
+ "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz",
+ "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz",
+ "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+ "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/auto-bind": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+ "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
+ "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18.20 <19 || >=20.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+ "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "restore-cursor": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz",
+ "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "slice-ansi": "^9.0.0",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/code-excerpt": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+ "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "convert-to-spaces": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/concurrently": {
+ "version": "10.0.5",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
+ "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "5.6.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.9.0",
+ "supports-color": "10.2.2",
+ "tree-kill": "1.2.2",
+ "yargs": "18.0.0"
+ },
+ "bin": {
+ "conc": "dist/bin/index.js",
+ "concurrently": "dist/bin/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/convert-to-spaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+ "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
+ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/indent-string": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ink": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz",
+ "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@alcalzone/ansi-tokenize": "^0.3.0",
+ "ansi-escapes": "^7.3.0",
+ "ansi-styles": "^6.2.3",
+ "auto-bind": "^5.0.1",
+ "chalk": "^5.6.2",
+ "cli-boxes": "^4.0.1",
+ "cli-cursor": "^4.0.0",
+ "cli-truncate": "^6.0.0",
+ "code-excerpt": "^4.0.0",
+ "es-toolkit": "^1.45.1",
+ "indent-string": "^5.0.0",
+ "is-in-ci": "^2.0.0",
+ "patch-console": "^2.0.0",
+ "react-reconciler": "^0.33.0",
+ "scheduler": "^0.27.0",
+ "signal-exit": "^3.0.7",
+ "slice-ansi": "^9.0.0",
+ "stack-utils": "^2.0.6",
+ "string-width": "^8.2.0",
+ "terminal-size": "^4.0.1",
+ "type-fest": "^5.5.0",
+ "widest-line": "^6.0.0",
+ "wrap-ansi": "^10.0.0",
+ "ws": "^8.20.0",
+ "yoga-layout": "~3.2.1"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@types/react": ">=19.2.0",
+ "react": ">=19.2.0",
+ "react-devtools-core": ">=6.1.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-devtools-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
+ "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "is-in-ci": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/laravel-vite-plugin": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
+ "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "tinyglobby": "^0.2.12",
+ "vite-plugin-full-reload": "^1.1.0"
+ },
+ "bin": {
+ "clean-orphaned-assets": "bin/clean.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "fontaine": "^0.8.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "fontaine": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-console": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
+ "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.33.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
+ "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+ "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
+ "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.148.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.7",
+ "@rolldown/binding-android-arm64": "1.2.7",
+ "@rolldown/binding-darwin-arm64": "1.2.7",
+ "@rolldown/binding-darwin-x64": "1.2.7",
+ "@rolldown/binding-freebsd-x64": "1.2.7",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.7",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.7",
+ "@rolldown/binding-linux-arm64-musl": "1.2.7",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.7",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.7",
+ "@rolldown/binding-linux-x64-gnu": "1.2.7",
+ "@rolldown/binding-linux-x64-musl": "1.2.7",
+ "@rolldown/binding-openharmony-arm64": "1.2.7",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.7",
+ "@rolldown/binding-win32-x64-msvc": "1.2.7"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/shell-quote": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/slice-ansi": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
+ "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/terminal-size": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
+ "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/type-fest": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
+ "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-full-reload": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
+ "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "picomatch": "^2.3.1"
+ }
+ },
+ "node_modules/vite-plugin-full-reload/node_modules/picomatch": {
+ "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": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz",
+ "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz",
+ "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yoga-layout": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
+ "license": "MIT",
+ "optional": true
+ }
+ }
+}
diff --git a/rsync-studio-licensing/package.json b/rsync-studio-licensing/package.json
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/phpunit.xml b/rsync-studio-licensing/phpunit.xml
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/public/.htaccess b/rsync-studio-licensing/public/.htaccess
old mode 100644
new mode 100755
diff --git a/rsync-studio-licensing/public/browserconfig.xml b/rsync-studio-licensing/public/browserconfig.xml
new file mode 100755
index 0000000..6492abb
--- /dev/null
+++ b/rsync-studio-licensing/public/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
Licences
Not revoked
Active installations
Portal users
Manage licences, activations and customer support.
Licences
Not revoked
Active installations
Portal users
Customer, licence, billing and privacy requests.
The support inbox is empty.
Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.
+ @if($paddleEnvironment === 'sandbox')Paddle is confirming your subscription. Your Rsync Studio licence key will be sent to the email used at checkout. You can then activate it in Rsync Studio → Settings → Licence.
+If the email takes a moment, wait for the Paddle confirmation and check your junk folder. You can also create a portal account with the same email address.
+ Create portal account +Last updated 9 September 2026
These are required for security and core site features and cannot be disabled through Cookie Settings.
Preference storage is optional and disabled unless you enable it. It may be used for non-essential interface choices in future versions of the site.
Analytics is currently disabled by default. If analytics tooling is added, it must only load after you enable Analytics in Cookie Settings and this policy must be updated to identify the provider and retention period.
Marketing storage is currently disabled by default. Marketing or advertising technology must not load before you expressly enable this category.
When you choose to start checkout, Paddle's checkout is loaded to provide payment and subscription functionality. Paddle may use storage that is necessary to provide checkout, prevent fraud and comply with payment obligations. Paddle provides its own privacy and cookie information for its service.
You can revisit your choice at any time.
Last updated 9 September 2026 · Policy version {{ config('rsync-studio.privacy.policy_version') }}
+We may process your name, email address, account credentials in hashed form, licence and activation identifiers, subscription and billing status, support requests, and technical server logs required to operate and secure the service. Rsync Studio does not send the files you sync, SSH passwords or private SSH keys to the licensing service.
Payments and subscription checkout are handled by Paddle. Payment-card details are entered into Paddle's checkout and are not stored by Rsync Studio. We receive identifiers and subscription information needed to provision and manage your licence. Paddle acts independently for parts of its payment and tax processing and provides its own privacy information.
Support requests are stored in our database and emailed to the configured support team. We retain resolved support tickets for up to {{ $retention }} days, unless a longer period is necessary for an unresolved dispute, security matter or legal obligation. A scheduled retention process removes eligible resolved tickets.
Licence, activation and subscription records are kept while your subscription or licence relationship is active and for a reasonable period afterwards where required for licence recovery, fraud prevention, accounting, dispute handling and legal obligations. We minimise retained data and do not use licence validation to inspect your synced files.
Information may be processed by service providers that are necessary to run Rsync Studio, such as Paddle for checkout and subscription processing, our hosting/infrastructure providers, and email delivery infrastructure. We limit disclosure to what is necessary for the relevant service and require appropriate safeguards where applicable.
Some service providers may process information outside the UK or EEA. Where data-protection law requires it, appropriate transfer mechanisms and safeguards must be used by the relevant controller or processor.
Necessary cookies are used for security, authentication, sessions and your privacy preferences. Optional analytics and marketing categories default to off and must not be loaded before consent. See our Cookie Policy or use .
Depending on the circumstances, you may have rights to access, correct, erase, restrict or object to processing of your personal data, request portability, and withdraw consent at any time where processing relies on consent. You also have the right to complain to the UK Information Commissioner's Office or your applicable supervisory authority.
Use the support form and choose “Privacy / data request”. We may need to verify your identity before acting on a request.
We use access controls, TLS, password hashing, CSRF protection, rate limiting and restricted administrative access. No internet service can guarantee absolute security, so we also minimise the personal data we collect.
Controller: {{ $legal['controller_name'] }}
Country: {{ $legal['country'] }}
Privacy contact: {{ $legal['privacy_email'] }}
Last updated 9 September 2026
Personal permits the number of active Mac installations shown for that plan at purchase. Professional permits the number shown for Professional. A licence is for your use and may not be shared, resold or used to bypass activation limits.
Where offered, the trial length and renewal terms are shown at checkout. Subscription billing, cancellation, tax and payment processing are handled through Paddle. Your subscription continues until cancelled in accordance with the checkout terms. Changes between Personal and Professional may be prorated by Paddle.
Rsync Studio uses a licence service to activate permitted Macs and periodically confirm that an activation remains valid. A reasonable offline grace period may be provided, but continued use after a subscription or activation is revoked is not authorised.
You are responsible for choosing correct source and destination paths, permissions, credentials, rsync options and deletion behaviour. Dry Run is provided to help review operations before execution, but you remain responsible for backups and for verifying important transfers.
You must not attempt to defeat licensing controls, interfere with the service, probe accounts you do not own, distribute malware, or use Rsync Studio in violation of applicable law or third-party rights.
We may update the application, website and licensing service to improve security, compatibility or functionality. We aim to provide a reliable service but do not guarantee uninterrupted availability.
Nothing in these terms excludes rights that cannot lawfully be excluded, including applicable UK consumer rights. Where mandatory law gives you stronger protections, that law prevails.
To the extent permitted by law, we are not responsible for indirect or consequential loss arising from incorrect sync configuration, deleted destination data, unavailable third-party systems or failures outside our reasonable control. This does not limit liability that cannot lawfully be limited.
Our processing of personal data is described in the Privacy Policy and Cookie Policy.
These terms are provided by {{ $legal['controller_name'] }} in {{ $legal['country'] }}. Questions can be submitted through Rsync Studio Support.
Licence ending {{ $license->license_key_last4 }} · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations
{{ $license->is_usable ? 'Active':'Unavailable' }}
ManageManage subscriptions, licence keys and activated Macs.
+No active Rsync Studio licence is associated with {{ auth()->user()->email }}.
+ Choose a plan +Licence ending {{ $license->license_key_last4 }}
+This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.
+{{ $license->edition }} · key ending {{ $license->license_key_last4 }}
| Installation | Platform | Version | Last seen | |
|---|---|---|---|---|
| {{ $a->installation_name ?: $a->installation_id }} | {{ $a->platform }} | {{ $a->app_version }} | {{ optional($a->last_seen_at)->toDayDateTimeString() }} |
Licence ending {{ $license->license_key_last4 }}
+{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.
+Manage your Rsync Studio licence, installations and recovery options securely.
@guestSign in@endguestBuild reliable local and SSH sync jobs, test them safely with Dry Run, schedule recurring transfers, watch folders for changes and keep a complete run history — all from one native Mac app.
+ +Personal for 1 Mac · Professional for up to 3 Macs · Monthly or annual
+Developer folder → remote server · SSH
+Sites folder → remote server · Daily
+Project folder · File watch
+Keep the power of rsync while replacing repetitive commands and scattered scripts with a clear, repeatable workflow.
+Create local copies or securely sync to remote Macs and Linux servers over SSH.
Preview changes before files are transferred or deleted, so risky jobs can be checked before they run.
Run jobs once, hourly, daily, weekly, monthly or on your own recurring interval.
Use macOS file events to trigger selected jobs when files or folders change.
Review commands, results, stdout, stderr and transfer statistics for previous runs.
Manage jobs, defaults, background operation and connection settings from a native macOS interface.
Choose your source, destination and rsync options once.
Check the generated operation before changing any files.
Start it manually, schedule it or attach a file watcher.
See the result and retain the command and transfer output.
Rsync Studio runs rsync and SSH from your Mac. The licensing service is separate from your file-transfer path.
+Both plans include the full Rsync Studio workflow and a 7-day free trial. Checkout shows pricing in your supported local currency.
+For a single Mac and an individual sync workflow.
+For multiple Macs or a broader development workflow.
+Start with a 7-day free trial and choose Personal or Professional at checkout.
Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.
+We will reply to the email address you provide.
+