mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
feat: add secure module marketplace runtime (#745)
This commit is contained in:
@@ -55,3 +55,10 @@ DOMPDF_ENABLE_REMOTE=false
|
||||
# Defaults to https://invoiceshelf.com. Override to point at a local website
|
||||
# checkout for development:
|
||||
# INVOICESHELF_BASE_URL=http://invoiceshelf-website.test
|
||||
|
||||
# Secure module marketplace. The official Ed25519 public key is built in.
|
||||
# Add rotation keys, or replace a key ID during an emergency rotation, with a
|
||||
# JSON key-id to base64 public-key map.
|
||||
# MARKETPLACE_PUBLIC_KEYS={"marketplace-2027":"base64-ed25519-public-key"}
|
||||
# MARKETPLACE_CHANNEL=stable
|
||||
# MARKETPLACE_MODULE_API_VERSION=1.0.0
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\HelloWorld\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HelloWorldController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('helloworld::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('helloworld::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('helloworld::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('helloworld::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\HelloWorld\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event handler mappings for the application.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
protected $listen = [];
|
||||
|
||||
/**
|
||||
* Indicates if events should be discovered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected static $shouldDiscoverEvents = true;
|
||||
|
||||
/**
|
||||
* Configure the proper event listeners for email verification.
|
||||
*/
|
||||
protected function configureEmailVerification(): void {}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\HelloWorld\Providers;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
use InvoiceShelf\Modules\Support\ModuleServiceProvider;
|
||||
|
||||
/**
|
||||
* Reference module that exercises every InvoiceShelf module-system surface.
|
||||
*
|
||||
* This file started life as the output of `php artisan module:make HelloWorld`
|
||||
* — which, thanks to the custom stubs shipped from the invoiceshelf/modules
|
||||
* package, already includes the Registry::registerMenu/registerSettings
|
||||
* skeleton. The schema below has been expanded beyond the stub default to
|
||||
* exercise more field types for test coverage.
|
||||
*/
|
||||
class HelloWorldServiceProvider extends ModuleServiceProvider
|
||||
{
|
||||
protected string $name = 'HelloWorld';
|
||||
|
||||
protected string $nameLower = 'helloworld';
|
||||
|
||||
protected array $providers = [
|
||||
EventServiceProvider::class,
|
||||
RouteServiceProvider::class,
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
$slug = Str::kebab($this->name);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Module script (Vue page registration)
|
||||
// ----------------------------------------------------------------
|
||||
// Registers a compiled JS file that the host app injects as a
|
||||
// <script type="module"> tag. The script calls
|
||||
// window.InvoiceShelf.booting() to add Vue routes before mount.
|
||||
ModuleRegistry::registerScript(
|
||||
$slug,
|
||||
module_path($this->name, 'resources/dist/init.js')
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Sidebar menu item
|
||||
// ----------------------------------------------------------------
|
||||
// Adds a link in the company sidebar under the "Modules" section.
|
||||
// The `title` uses a Laravel translation key (namespace::file.key)
|
||||
// resolved server-side before being sent to the Vue frontend.
|
||||
// The `link` points to the module's own Vue page (registered by init.js).
|
||||
// The `icon` is any Heroicon name available in BaseIcon.
|
||||
ModuleRegistry::registerMenu($slug, [
|
||||
'title' => $this->nameLower.'::menu.title',
|
||||
'link' => '/admin/modules/'.$slug.'/dashboard',
|
||||
'icon' => 'HandRaisedIcon',
|
||||
]);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Schema-driven settings
|
||||
// ----------------------------------------------------------------
|
||||
// Registers a settings form that appears in the company modules
|
||||
// page. Each company configures settings independently. Values are
|
||||
// persisted as CompanySetting keys: module.{slug}.{field_key}.
|
||||
//
|
||||
// Supported field types: text, textarea, select, switch, number
|
||||
// Labels use Laravel translation keys, resolved server-side.
|
||||
ModuleRegistry::registerSettings($slug, [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => $this->nameLower.'::settings.greeting_section',
|
||||
'fields' => [
|
||||
[
|
||||
'key' => 'greeting',
|
||||
'type' => 'text',
|
||||
'label' => $this->nameLower.'::settings.greeting',
|
||||
'rules' => ['required', 'max:120'],
|
||||
'default' => 'Hello, world!',
|
||||
],
|
||||
[
|
||||
'key' => 'recipient',
|
||||
'type' => 'text',
|
||||
'label' => $this->nameLower.'::settings.recipient',
|
||||
'rules' => ['max:60'],
|
||||
'default' => 'friend',
|
||||
],
|
||||
[
|
||||
'key' => 'show_emoji',
|
||||
'type' => 'switch',
|
||||
'label' => $this->nameLower.'::settings.show_emoji',
|
||||
'default' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => $this->nameLower.'::settings.style_section',
|
||||
'fields' => [
|
||||
[
|
||||
'key' => 'tone',
|
||||
'type' => 'select',
|
||||
'label' => $this->nameLower.'::settings.tone',
|
||||
'rules' => ['required'],
|
||||
'default' => 'friendly',
|
||||
'options' => [
|
||||
'friendly' => 'Friendly',
|
||||
'formal' => 'Formal',
|
||||
'enthusiastic' => 'Enthusiastic',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'note',
|
||||
'type' => 'textarea',
|
||||
'label' => $this->nameLower.'::settings.note',
|
||||
'rules' => ['max:500'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\HelloWorld\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $name = 'HelloWorld';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map(): void
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*/
|
||||
protected function mapWebRoutes(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*/
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "invoiceshelf/helloworld",
|
||||
"description": "",
|
||||
"authors": [
|
||||
{
|
||||
"name": "InvoiceShelf",
|
||||
"email": "hello@invoiceshelf.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"invoiceshelf/modules": "^3.0"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [],
|
||||
"aliases": {
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\HelloWorld\\": "app/",
|
||||
"Modules\\HelloWorld\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\HelloWorld\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\HelloWorld\\Tests\\": "tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'HelloWorld',
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\HelloWorld\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class HelloWorldDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// $this->call([]);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Hello World',
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'greeting_section' => 'Greeting',
|
||||
'style_section' => 'Style',
|
||||
|
||||
'greeting' => 'Greeting message',
|
||||
'recipient' => 'Recipient name',
|
||||
'show_emoji' => 'Show emoji',
|
||||
'tone' => 'Tone',
|
||||
'note' => 'Additional note (optional)',
|
||||
];
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "HelloWorld",
|
||||
"alias": "helloworld",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\HelloWorld\\Providers\\HelloWorldServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
const { createBlock: e, createElementVNode: t, createTextVNode: n, createVNode: r, openBlock: i, ref: a, resolveComponent: o, toDisplayString: s, withCtx: c } = window.__invoiceshelf_vue;
|
||||
//#region resources/js/pages/DashboardPage.vue
|
||||
var l = { class: "p-6" }, u = { class: "flex items-center gap-3" }, d = { class: "text-xl font-semibold text-heading" }, f = { class: "mt-6 rounded-lg bg-surface-secondary p-4" }, p = { class: "space-y-1.5 text-sm text-muted" }, m = { class: "flex items-start gap-2" }, h = { class: "flex items-start gap-2" }, g = { class: "flex items-start gap-2" }, _ = {
|
||||
__name: "DashboardPage",
|
||||
setup(_) {
|
||||
let v = a("Hello, world!");
|
||||
return (a, _) => {
|
||||
let y = o("BaseBreadcrumbItem"), b = o("BaseBreadcrumb"), x = o("BasePageHeader"), S = o("BaseIcon"), C = o("BaseCard"), w = o("BasePage");
|
||||
return i(), e(w, null, {
|
||||
default: c(() => [r(x, { title: "Hello World" }, {
|
||||
default: c(() => [r(b, null, {
|
||||
default: c(() => [r(y, {
|
||||
title: "Home",
|
||||
to: "dashboard"
|
||||
}), r(y, {
|
||||
title: "Hello World",
|
||||
to: "#",
|
||||
active: ""
|
||||
})]),
|
||||
_: 1
|
||||
})]),
|
||||
_: 1
|
||||
}), r(C, { class: "mt-6" }, {
|
||||
default: c(() => [t("div", l, [
|
||||
t("div", u, [r(S, {
|
||||
name: "HandRaisedIcon",
|
||||
class: "h-8 w-8 text-primary-500"
|
||||
}), t("h2", d, s(v.value), 1)]),
|
||||
_[12] ||= t("p", { class: "mt-3 text-sm text-muted leading-relaxed" }, [
|
||||
n(" This page is provided by the "),
|
||||
t("strong", null, "HelloWorld"),
|
||||
n(" module. It demonstrates how modules can ship their own Vue pages that render inside the InvoiceShelf SPA using globally registered Base components. ")
|
||||
], -1),
|
||||
t("div", f, [_[11] ||= t("h3", { class: "text-sm font-semibold text-heading mb-2" }, "How it works", -1), t("ul", p, [
|
||||
t("li", m, [
|
||||
r(S, {
|
||||
name: "CheckIcon",
|
||||
class: "h-4 w-4 text-green-500 shrink-0 mt-0.5"
|
||||
}),
|
||||
_[0] ||= n(" Module ships a compiled ", -1),
|
||||
_[1] ||= t("code", { class: "text-primary-600" }, "init.js", -1),
|
||||
_[2] ||= n(" that calls ", -1),
|
||||
_[3] ||= t("code", { class: "text-primary-600" }, "window.InvoiceShelf.booting()", -1)
|
||||
]),
|
||||
t("li", h, [
|
||||
r(S, {
|
||||
name: "CheckIcon",
|
||||
class: "h-4 w-4 text-green-500 shrink-0 mt-0.5"
|
||||
}),
|
||||
_[4] ||= n(" The callback receives ", -1),
|
||||
_[5] ||= t("code", { class: "text-primary-600" }, "(app, router)", -1),
|
||||
_[6] ||= n(" and adds routes via ", -1),
|
||||
_[7] ||= t("code", { class: "text-primary-600" }, "router.addRoute()", -1)
|
||||
]),
|
||||
t("li", g, [
|
||||
r(S, {
|
||||
name: "CheckIcon",
|
||||
class: "h-4 w-4 text-green-500 shrink-0 mt-0.5"
|
||||
}),
|
||||
_[8] ||= n(" Vue pages use globally registered ", -1),
|
||||
_[9] ||= t("code", { class: "text-primary-600" }, "Base*", -1),
|
||||
_[10] ||= n(" components — no imports needed ", -1)
|
||||
])
|
||||
])])
|
||||
])]),
|
||||
_: 1
|
||||
})]),
|
||||
_: 1
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
//#endregion
|
||||
//#region resources/js/init.ts
|
||||
window.InvoiceShelf.booting((e, t) => {
|
||||
t.addRoute("admin", {
|
||||
path: "modules/hello-world/dashboard",
|
||||
name: "modules.hello-world.dashboard",
|
||||
component: _,
|
||||
meta: { requiresAuth: !0 }
|
||||
});
|
||||
});
|
||||
//#endregion
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* HelloWorld module entry point.
|
||||
*
|
||||
* This file is loaded by the host app via a <script type="module"> tag
|
||||
* injected by Registry::allScripts(). It runs BEFORE the Vue app mounts,
|
||||
* so router.addRoute() works reliably.
|
||||
*
|
||||
* All Base* components (BaseCard, BaseIcon, BasePage, etc.) are globally
|
||||
* registered by the host — no imports needed in your Vue templates.
|
||||
*/
|
||||
import DashboardPage from './pages/DashboardPage.vue'
|
||||
|
||||
window.InvoiceShelf.booting((_app, router) => {
|
||||
// Register a Vue page route
|
||||
router.addRoute('admin', {
|
||||
path: 'modules/hello-world/dashboard',
|
||||
name: 'modules.hello-world.dashboard',
|
||||
component: DashboardPage,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader title="Hello World">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem title="Home" to="dashboard" />
|
||||
<BaseBreadcrumbItem title="Hello World" to="#" active />
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<BaseCard class="mt-6">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseIcon name="HandRaisedIcon" class="h-8 w-8 text-primary-500" />
|
||||
<h2 class="text-xl font-semibold text-heading">{{ greeting }}</h2>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-muted leading-relaxed">
|
||||
This page is provided by the <strong>HelloWorld</strong> module.
|
||||
It demonstrates how modules can ship their own Vue pages that
|
||||
render inside the InvoiceShelf SPA using globally registered
|
||||
Base components.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-surface-secondary p-4">
|
||||
<h3 class="text-sm font-semibold text-heading mb-2">How it works</h3>
|
||||
<ul class="space-y-1.5 text-sm text-muted">
|
||||
<li class="flex items-start gap-2">
|
||||
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
Module ships a compiled <code class="text-primary-600">init.js</code> that calls <code class="text-primary-600">window.InvoiceShelf.booting()</code>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
The callback receives <code class="text-primary-600">(app, router)</code> and adds routes via <code class="text-primary-600">router.addRoute()</code>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
Vue pages use globally registered <code class="text-primary-600">Base*</code> components — no imports needed
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const greeting = ref('Hello, world!')
|
||||
</script>
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Vue runtime shim for InvoiceShelf modules.
|
||||
*
|
||||
* The host app exposes its Vue instance on window.__invoiceshelf_vue.
|
||||
* This shim provides a Proxy-based default export that lazily resolves
|
||||
* Vue APIs on first access — avoiding the crash that happens when the
|
||||
* module script evaluates before the host has set the global.
|
||||
*
|
||||
* For named exports (used by SFC compiled templates), we use a Proxy
|
||||
* as the module namespace. Vite's lib mode with a default export
|
||||
* from a Proxy works because the compiled SFC template accesses
|
||||
* the APIs at render time (long after the host has initialized),
|
||||
* not at module evaluation time.
|
||||
*/
|
||||
|
||||
function getVue() {
|
||||
const vue = window.__invoiceshelf_vue
|
||||
if (!vue) {
|
||||
throw new Error(
|
||||
'[InvoiceShelf Module] Host Vue runtime not available. ' +
|
||||
'Ensure the module script loads after the host app.'
|
||||
)
|
||||
}
|
||||
return vue
|
||||
}
|
||||
|
||||
// Proxy that forwards all property access to the host's Vue at call time
|
||||
const vueProxy = new Proxy({}, {
|
||||
get(_, key) {
|
||||
return getVue()[key]
|
||||
},
|
||||
has(_, key) {
|
||||
return key in getVue()
|
||||
},
|
||||
})
|
||||
|
||||
export default vueProxy
|
||||
@@ -1,30 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<title>HelloWorld Module - {{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<meta name="description" content="{{ $description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $keywords ?? '' }}">
|
||||
<meta name="author" content="{{ $author ?? '' }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
{{-- Vite CSS --}}
|
||||
{{-- {{ module_vite('build-helloworld', 'resources/assets/sass/app.scss') }} --}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Vite JS --}}
|
||||
{{-- {{ module_vite('build-helloworld', 'resources/assets/js/app.js') }} --}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
<x-helloworld::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('helloworld.name') !!}</p>
|
||||
</x-helloworld::layouts.master>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\HelloWorld\Http\Controllers\HelloWorldController;
|
||||
|
||||
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
||||
Route::apiResource('helloworlds', HelloWorldController::class)->names('helloworld');
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\HelloWorld\Http\Controllers\HelloWorldController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::resource('helloworlds', HelloWorldController::class)->names('helloworld');
|
||||
});
|
||||
Vendored
-69
@@ -1,69 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
/**
|
||||
* Module build config.
|
||||
*
|
||||
* Produces a single `resources/dist/init.js` ES module that the host app
|
||||
* loads via <script type="module">. Vue is externalized and resolved from
|
||||
* the host's `window.__invoiceshelf_vue` global at runtime.
|
||||
*
|
||||
* The vueGlobalPlugin rewrites `import { ref, ... } from "vue"` into
|
||||
* destructuring from the host global — no separate Vue bundle, no import
|
||||
* map, and the module shares the host's Vue instance so globally
|
||||
* registered Base* components are accessible via resolveComponent().
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vite plugin that replaces Vue imports with the host's global at runtime.
|
||||
* Works by marking 'vue' as external and then rewriting the import in the
|
||||
* output via renderChunk.
|
||||
*/
|
||||
function vueGlobalPlugin() {
|
||||
return {
|
||||
name: 'invoiceshelf-vue-global',
|
||||
enforce: 'pre',
|
||||
resolveId(source) {
|
||||
if (source === 'vue') {
|
||||
return { id: 'vue', external: true }
|
||||
}
|
||||
},
|
||||
renderChunk(code) {
|
||||
// Replace: import { ref, computed, ... } from "vue";
|
||||
// With: const { ref, computed, ... } = window.__invoiceshelf_vue;
|
||||
return code.replace(
|
||||
/import\s*(\{[^}]+\})\s*from\s*"vue"\s*;?/g,
|
||||
'const $1 = window.__invoiceshelf_vue;'
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'resources/dist'),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'resources/js/init.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'init.js',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
vueGlobalPlugin(),
|
||||
vue({
|
||||
template: {
|
||||
transformAssetUrls: {
|
||||
base: null,
|
||||
includeAbsolute: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'resources/js'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MarketplaceCredential;
|
||||
use App\Services\Marketplace\MarketplaceClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class MarketplacePairingController extends Controller
|
||||
{
|
||||
public function __construct(private MarketplaceClient $client) {}
|
||||
|
||||
public function start(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$response = $this->client->beginPairing();
|
||||
$data = $response->json();
|
||||
if (! $response->successful() || ! is_array($data) || ! is_string($data['device_code'] ?? null)) {
|
||||
return response()->json(['error' => 'marketplace_unavailable'], 503);
|
||||
}
|
||||
|
||||
$ttl = max(60, (int) ($data['expires_in'] ?? 600));
|
||||
Cache::put($this->cacheKey(), $data['device_code'], now()->addSeconds($ttl));
|
||||
|
||||
return response()->json([
|
||||
'device_code' => $data['device_code'],
|
||||
'user_code' => $data['user_code'] ?? null,
|
||||
'verification_uri' => $data['verification_uri'] ?? $data['verification_uri_complete'] ?? null,
|
||||
'verification_uri_complete' => $data['verification_uri_complete'] ?? null,
|
||||
'expires_in' => $ttl,
|
||||
'interval' => max(1, (int) ($data['interval'] ?? 5)),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function poll(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$deviceCode = Cache::get($this->cacheKey());
|
||||
if (! is_string($deviceCode)) {
|
||||
return response()->json(['error' => 'pairing_expired'], 422);
|
||||
}
|
||||
|
||||
$response = $this->client->pollPairing($deviceCode);
|
||||
$data = $response->json();
|
||||
if ($response->status() === 428 || ($data['error'] ?? null) === 'authorization_pending') {
|
||||
return response()->json(['status' => 'pending']);
|
||||
}
|
||||
if (! $response->successful() || ! is_array($data) || ! is_string($data['installation_token'] ?? null)) {
|
||||
return response()->json(['error' => 'pairing_failed'], 422);
|
||||
}
|
||||
|
||||
MarketplaceCredential::query()->delete();
|
||||
MarketplaceCredential::query()->create([
|
||||
'credential' => Crypt::encryptString($data['installation_token']),
|
||||
'device_id' => is_scalar($data['installation']['id'] ?? null) ? (string) $data['installation']['id'] : null,
|
||||
'paired_at' => now(),
|
||||
]);
|
||||
Cache::forget($this->cacheKey());
|
||||
|
||||
return response()->json(['status' => 'paired']);
|
||||
}
|
||||
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$credential = MarketplaceCredential::query()->latest('id')->first();
|
||||
|
||||
return response()->json([
|
||||
'paired' => $credential !== null,
|
||||
'expired' => $credential?->expires_at?->isPast() ?? false,
|
||||
'paired_at' => $credential?->paired_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function disconnect(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
if (MarketplaceCredential::query()->exists()) {
|
||||
// Revocation releases any entitlement activation tied to this
|
||||
// installation. Local disconnect still succeeds if the control
|
||||
// plane is temporarily unavailable.
|
||||
$this->client->revokeInstallation();
|
||||
}
|
||||
MarketplaceCredential::query()->delete();
|
||||
Cache::forget($this->cacheKey());
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
private function cacheKey(): string
|
||||
{
|
||||
return 'marketplace.device-pairing';
|
||||
}
|
||||
}
|
||||
@@ -3,67 +3,22 @@
|
||||
namespace App\Http\Controllers\Admin\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UnzipUpdateRequest;
|
||||
use App\Http\Requests\UploadModuleRequest;
|
||||
use App\Support\Module\ModuleInstaller;
|
||||
use App\Http\Requests\InstallMarketplaceModuleRequest;
|
||||
use App\Services\Marketplace\MarketplaceInstaller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ModuleInstallationController extends Controller
|
||||
{
|
||||
public function download(Request $request): JsonResponse
|
||||
public function install(InstallMarketplaceModuleRequest $request, MarketplaceInstaller $installer): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::download(
|
||||
(string) $request->slug,
|
||||
(string) $request->version,
|
||||
$request->checksum_sha256 ? (string) $request->checksum_sha256 : null,
|
||||
$response = $installer->install(
|
||||
$request->string('slug')->toString(),
|
||||
$request->string('version')->toString(),
|
||||
$request->string('channel')->toString() ?: (string) config('invoiceshelf.marketplace.channel', 'stable'),
|
||||
);
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function upload(UploadModuleRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::upload($request);
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function unzip(UnzipUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$path = ModuleInstaller::unzip($request->module_name ?? $request->module, $request->path);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
|
||||
public function copy(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::copyFiles($request->module_name ?? $request->module, $request->path);
|
||||
|
||||
return response()->json([
|
||||
'success' => $response,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::complete($request->module_name ?? $request->module, $request->version);
|
||||
|
||||
return response()->json([
|
||||
'success' => $response,
|
||||
]);
|
||||
return response()->json($response, $response['success'] ? 200 : 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,55 +7,51 @@ use App\Events\ModuleEnabledEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ModuleResource;
|
||||
use App\Models\Module as ModelsModule;
|
||||
use App\Support\Module\ModuleInstaller;
|
||||
use App\Services\Marketplace\MarketplaceClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
|
||||
class ModulesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(MarketplaceClient $client)
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::getModules();
|
||||
$response = $client->catalog();
|
||||
$body = $response->json();
|
||||
$modules = is_array($body) ? ($body['modules'] ?? $body['data'] ?? null) : null;
|
||||
|
||||
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->modules)) {
|
||||
if (! $response->successful() || ! is_array($modules)) {
|
||||
return response()->json(['error' => 'marketplace_unavailable'], 503);
|
||||
}
|
||||
|
||||
return ModuleResource::collection(collect($response['body']->modules));
|
||||
return ModuleResource::collection(collect($modules));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $module)
|
||||
public function show(string $module, MarketplaceClient $client)
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::getModule($module);
|
||||
$response = $client->module($module);
|
||||
$body = $response->json();
|
||||
|
||||
if (($response['status'] ?? 0) === 404) {
|
||||
if ($response->status() === 404) {
|
||||
return response()->json(['error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->data)) {
|
||||
if (! $response->successful() || ! is_array($body) || ! is_array($body['module'] ?? null)) {
|
||||
return response()->json(['error' => 'marketplace_unavailable'], 503);
|
||||
}
|
||||
|
||||
return (new ModuleResource($response['body']->data))
|
||||
return (new ModuleResource($body['module']))
|
||||
->additional(['meta' => [
|
||||
'modules' => ModuleResource::collection(
|
||||
collect($response['body']->meta->modules ?? [])
|
||||
collect($body['meta']['modules'] ?? [])
|
||||
),
|
||||
]]);
|
||||
}
|
||||
|
||||
public function checkToken(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
return ModuleInstaller::checkToken($request->api_token);
|
||||
}
|
||||
|
||||
public function enable(Request $request, string $module): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
@@ -40,7 +40,6 @@ class BootstrapController extends Controller
|
||||
->get();
|
||||
|
||||
$global_settings = Setting::getSettings([
|
||||
'api_token',
|
||||
'admin_portal_theme',
|
||||
'admin_portal_logo',
|
||||
'login_page_logo',
|
||||
|
||||
@@ -20,16 +20,16 @@ use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
* settings (per CompanySetting under the module.{slug}.* prefix).
|
||||
*
|
||||
* Slug convention: nwidart stores the module's PascalCase class name in
|
||||
* `modules.name` (e.g. "HelloWorld"), but URLs and registry keys use the
|
||||
* kebab-case form ("hello-world") for readability. We normalize via
|
||||
* Str::kebab() so module authors can call Registry::registerMenu('hello-world')
|
||||
* `modules.name` (e.g. "SalesTaxUs"), but URLs and registry keys use the
|
||||
* kebab-case form ("sales-tax-us") for readability. We normalize via
|
||||
* Str::kebab() so module authors can call Registry::registerMenu('sales-tax-us')
|
||||
* naturally without thinking about the storage format.
|
||||
*/
|
||||
class CompanyModulesController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$this->authorize('manage module settings');
|
||||
|
||||
$modules = Module::query()
|
||||
->where('enabled', true)
|
||||
|
||||
@@ -25,7 +25,7 @@ class ModuleSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$this->authorize('manage module settings');
|
||||
|
||||
$schema = ModuleRegistry::settingsFor($slug);
|
||||
|
||||
@@ -50,7 +50,7 @@ class ModuleSettingsController extends Controller
|
||||
|
||||
public function update(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
$this->authorize('manage module settings');
|
||||
|
||||
$schema = ModuleRegistry::settingsFor($slug);
|
||||
|
||||
@@ -139,7 +139,7 @@ class ModuleSettingsController extends Controller
|
||||
/**
|
||||
* Translate section titles and field labels in the schema so the
|
||||
* frontend receives ready-to-display strings instead of Laravel
|
||||
* translation keys it cannot resolve (e.g. `helloworld::settings.greeting`).
|
||||
* translation keys it cannot resolve (e.g. `sales_tax_us::settings.greeting`).
|
||||
*
|
||||
* @param array{sections: list<array<string, mixed>>} $schema
|
||||
* @return array{sections: list<array<string, mixed>>}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Module\ModuleAssetVersion;
|
||||
use DateTime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -23,14 +24,25 @@ class ScriptController extends Controller
|
||||
{
|
||||
$path = ModuleRegistry::scriptFor($script);
|
||||
|
||||
abort_if($path === null, 404);
|
||||
abort_if($path === null || ! is_file($path), 404);
|
||||
|
||||
return response(
|
||||
file_get_contents($path),
|
||||
$contents = file_get_contents($path);
|
||||
abort_if(! is_string($contents), 404);
|
||||
$version = ModuleAssetVersion::forContents($contents);
|
||||
$cacheControl = is_string($request->query('v')) && hash_equals($version, $request->query('v'))
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-store';
|
||||
|
||||
$response = response(
|
||||
$contents,
|
||||
200,
|
||||
[
|
||||
'Content-Type' => 'application/javascript',
|
||||
]
|
||||
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
|
||||
|
||||
$response->headers->set('Cache-Control', $cacheControl);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Module\ModuleAssetVersion;
|
||||
use DateTime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -23,14 +24,25 @@ class StyleController extends Controller
|
||||
{
|
||||
$path = ModuleRegistry::styleFor($style);
|
||||
|
||||
abort_if($path === null, 404);
|
||||
abort_if($path === null || ! is_file($path), 404);
|
||||
|
||||
return response(
|
||||
file_get_contents($path),
|
||||
$contents = file_get_contents($path);
|
||||
abort_if(! is_string($contents), 404);
|
||||
$version = ModuleAssetVersion::forContents($contents);
|
||||
$cacheControl = is_string($request->query('v')) && hash_equals($version, $request->query('v'))
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-store';
|
||||
|
||||
$response = response(
|
||||
$contents,
|
||||
200,
|
||||
[
|
||||
'Content-Type' => 'text/css',
|
||||
]
|
||||
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
|
||||
|
||||
$response->headers->set('Cache-Control', $cacheControl);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InstallMarketplaceModuleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<int, string>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'slug' => ['required', 'string', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', 'max:100'],
|
||||
'version' => ['required', 'string', 'regex:/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/', 'max:80'],
|
||||
'channel' => ['nullable', 'in:stable,insider'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,42 +17,57 @@ class ModuleResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$installedModule = ModelsModule::where('name', $this->module_name)->first();
|
||||
$moduleName = data_get($this->resource, 'module_name');
|
||||
$installedModule = is_string($moduleName)
|
||||
? ModelsModule::where('name', $moduleName)->first()
|
||||
: null;
|
||||
$release = data_get($this->resource, 'release');
|
||||
$latestVersion = data_get($this->resource, 'latest_module_version') ?? data_get($release, 'version');
|
||||
$compatibility = data_get($this->resource, 'compatibility') ?? data_get($release, 'compatibility');
|
||||
$access = data_get($this->resource, 'access', 'free');
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'average_rating' => $this->average_rating,
|
||||
'cover' => $this->cover,
|
||||
'slug' => $this->slug,
|
||||
'module_name' => $this->module_name,
|
||||
'access_tier' => $this->access_tier ?? 'public',
|
||||
'faq' => $this->faq,
|
||||
'highlights' => $this->highlights,
|
||||
'id' => data_get($this->resource, 'id'),
|
||||
'average_rating' => data_get($this->resource, 'average_rating'),
|
||||
'cover' => data_get($this->resource, 'cover'),
|
||||
'slug' => data_get($this->resource, 'slug'),
|
||||
'module_name' => $moduleName,
|
||||
'access_tier' => data_get($this->resource, 'access_tier') ?? ($access === 'paid' ? 'premium' : 'public'),
|
||||
'access' => $access,
|
||||
'entitlement' => data_get($this->resource, 'entitlement'),
|
||||
'compatibility' => $compatibility,
|
||||
'compatible' => data_get($this->resource, 'compatible'),
|
||||
'release_state' => data_get($this->resource, 'release_state') ?? data_get($release, 'state') ?? 'published',
|
||||
'yanked_reason' => data_get($this->resource, 'yanked_reason') ?? data_get($release, 'yanked_reason'),
|
||||
'channel' => data_get($this->resource, 'channel') ?? data_get($release, 'channel') ?? 'stable',
|
||||
'faq' => data_get($this->resource, 'faq'),
|
||||
'highlights' => data_get($this->resource, 'highlights'),
|
||||
'installed_module_version' => $this->getInstalledModuleVersion($installedModule),
|
||||
'installed_module_version_updated_at' => $this->getInstalledModuleUpdatedAt($installedModule),
|
||||
'latest_module_version' => $this->latest_module_version,
|
||||
'latest_module_version_updated_at' => $this->latest_module_version_updated_at,
|
||||
'latest_min_invoiceshelf_version' => $this->latest_min_invoiceshelf_version ?? null,
|
||||
'latest_module_checksum_sha256' => $this->latest_module_checksum_sha256 ?? null,
|
||||
'is_dev' => $this->is_dev,
|
||||
'license' => $this->license,
|
||||
'long_description' => $this->long_description,
|
||||
'monthly_price' => $this->monthly_price,
|
||||
'name' => $this->name,
|
||||
'purchased' => $this->purchased ?? true,
|
||||
'reviews' => $this->reviews ?? [],
|
||||
'screenshots' => $this->screenshots,
|
||||
'short_description' => $this->short_description,
|
||||
'type' => $this->type,
|
||||
'yearly_price' => $this->yearly_price,
|
||||
'author_name' => $this->author_name,
|
||||
'author_avatar' => $this->author_avatar,
|
||||
'latest_module_version' => $latestVersion,
|
||||
'latest_module_version_updated_at' => data_get($this->resource, 'latest_module_version_updated_at') ?? data_get($release, 'published_at'),
|
||||
'latest_min_invoiceshelf_version' => data_get($this->resource, 'latest_min_invoiceshelf_version'),
|
||||
'latest_module_checksum_sha256' => data_get($this->resource, 'latest_module_checksum_sha256') ?? data_get($release, 'artifact.sha256'),
|
||||
'is_dev' => (bool) data_get($this->resource, 'is_dev', false),
|
||||
'license' => data_get($this->resource, 'license', 'AGPL-3.0-only'),
|
||||
'long_description' => data_get($this->resource, 'long_description'),
|
||||
'monthly_price' => data_get($this->resource, 'monthly_price'),
|
||||
'name' => data_get($this->resource, 'name'),
|
||||
'purchased' => data_get($this->resource, 'purchased') ?? ($access === 'free' || (bool) data_get($this->resource, 'entitlement.active', false)),
|
||||
'purchase_url' => data_get($this->resource, 'purchase_url'),
|
||||
'reviews' => data_get($this->resource, 'reviews', []),
|
||||
'screenshots' => data_get($this->resource, 'screenshots'),
|
||||
'short_description' => data_get($this->resource, 'short_description'),
|
||||
'type' => data_get($this->resource, 'type'),
|
||||
'yearly_price' => data_get($this->resource, 'yearly_price'),
|
||||
'author_name' => data_get($this->resource, 'author_name') ?? data_get($this->resource, 'author.name'),
|
||||
'author_avatar' => data_get($this->resource, 'author_avatar') ?? data_get($this->resource, 'author.avatar'),
|
||||
'installed' => $this->moduleInstalled($installedModule),
|
||||
'enabled' => $this->moduleEnabled($installedModule),
|
||||
'update_available' => $this->updateAvailable($installedModule),
|
||||
'video_link' => $this->video_link,
|
||||
'video_thumbnail' => $this->video_thumbnail,
|
||||
'links' => $this->links,
|
||||
'update_available' => $this->updateAvailable($installedModule, $latestVersion),
|
||||
'video_link' => data_get($this->resource, 'video_link') ?? data_get($this->resource, 'video.url'),
|
||||
'video_thumbnail' => data_get($this->resource, 'video_thumbnail') ?? data_get($this->resource, 'video.thumbnail'),
|
||||
'links' => data_get($this->resource, 'links'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,16 +99,16 @@ class ModuleResource extends JsonResource
|
||||
return (bool) ($installedModule?->installed && $installedModule?->enabled);
|
||||
}
|
||||
|
||||
public function updateAvailable(?ModelsModule $installedModule): bool
|
||||
public function updateAvailable(?ModelsModule $installedModule, mixed $latestVersion): bool
|
||||
{
|
||||
if (! $installedModule || ! $installedModule->installed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! isset($this->latest_module_version) || ! is_string($this->latest_module_version)) {
|
||||
if (! is_string($latestVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return version_compare($installedModule->version, $this->latest_module_version, '<');
|
||||
return version_compare($installedModule->version, $latestVersion, '<');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MarketplaceCredential extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['credential'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'paired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MarketplaceOperation extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,13 @@ class Module extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'installed' => 'boolean',
|
||||
'enabled' => 'boolean',
|
||||
'last_failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,6 @@ class ModulesPolicy
|
||||
|
||||
public function manageModules(User $user)
|
||||
{
|
||||
if ($user->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->isOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\AiConversation;
|
||||
use App\Models\User;
|
||||
use App\Policies\AiConversationPolicy;
|
||||
use App\Policies\CompanyPolicy;
|
||||
use App\Policies\CreditNotePolicy;
|
||||
@@ -68,6 +69,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Gate::policy(Role::class, RolePolicy::class);
|
||||
Gate::policy(AiConversation::class, AiConversationPolicy::class);
|
||||
Gate::define('manage module settings', fn (User $user): bool => $user->isSuperAdmin() || $user->isOwner());
|
||||
|
||||
View::addNamespace('pdf_templates', storage_path('app/templates/pdf'));
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketplace;
|
||||
|
||||
use JsonException;
|
||||
|
||||
class CanonicalJson
|
||||
{
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
public static function encode(array $value): string
|
||||
{
|
||||
return json_encode(
|
||||
self::sort($value),
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
private static function sort(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value, SORT_STRING);
|
||||
}
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = self::sort($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketplace;
|
||||
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Nwidart\Modules\Activators\FileActivator;
|
||||
use Nwidart\Modules\Contracts\ActivatorInterface;
|
||||
use Nwidart\Modules\Module;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Instance activation is durable database state. The file activator is used
|
||||
* only while the database is unavailable during application bootstrap.
|
||||
*/
|
||||
class DatabaseActivator implements ActivatorInterface
|
||||
{
|
||||
private FileActivator $fallback;
|
||||
|
||||
public function __construct(Container $app)
|
||||
{
|
||||
$this->fallback = new FileActivator($app);
|
||||
}
|
||||
|
||||
public function enable(Module $module): void
|
||||
{
|
||||
$this->setActiveByName($module->getName(), true);
|
||||
}
|
||||
|
||||
public function disable(Module $module): void
|
||||
{
|
||||
$this->setActiveByName($module->getName(), false);
|
||||
}
|
||||
|
||||
public function hasStatus(Module|string $module, bool $status): bool
|
||||
{
|
||||
$name = $module instanceof Module ? $module->getName() : $module;
|
||||
|
||||
if (! $this->databaseReady()) {
|
||||
return $this->fallback->hasStatus($name, $status);
|
||||
}
|
||||
|
||||
$installed = DB::table('modules')->where('name', $name)->first();
|
||||
|
||||
return $installed === null ? $status === false : (bool) $installed->enabled === $status;
|
||||
}
|
||||
|
||||
public function setActive(Module $module, bool $active): void
|
||||
{
|
||||
$this->setActiveByName($module->getName(), $active);
|
||||
}
|
||||
|
||||
public function setActiveByName(string $name, bool $active): void
|
||||
{
|
||||
if (! $this->databaseReady()) {
|
||||
$this->fallback->setActiveByName($name, $active);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$module = DB::table('modules')->where('name', $name)->first();
|
||||
if ($module !== null) {
|
||||
DB::table('modules')->where('name', $name)->update(['enabled' => $active, 'updated_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('modules')->insert([
|
||||
'name' => $name,
|
||||
'version' => '0.0.0',
|
||||
'installed' => true,
|
||||
'enabled' => $active,
|
||||
'state' => 'installed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(Module $module): void
|
||||
{
|
||||
if (! $this->databaseReady()) {
|
||||
$this->fallback->delete($module);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('modules')->where('name', $module->getName())->update(['enabled' => false, 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
if (! $this->databaseReady()) {
|
||||
$this->fallback->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('modules')->update(['enabled' => false, 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
private function databaseReady(): bool
|
||||
{
|
||||
if (! app()->bound('db')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Schema::hasTable('modules') && Schema::hasColumn('modules', 'state');
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketplace;
|
||||
|
||||
use App\Models\MarketplaceCredential;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class MarketplaceClient
|
||||
{
|
||||
private const API_PREFIX = 'api/marketplace/v1';
|
||||
|
||||
public function catalog(): Response
|
||||
{
|
||||
return $this->request()->get(self::API_PREFIX.'/modules', [
|
||||
'channel' => config('invoiceshelf.marketplace.channel', 'stable'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function module(string $slug): Response
|
||||
{
|
||||
return $this->request()->get(self::API_PREFIX.'/modules/'.$slug, [
|
||||
'channel' => config('invoiceshelf.marketplace.channel', 'stable'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function beginPairing(): Response
|
||||
{
|
||||
return $this->request()->post(self::API_PREFIX.'/device/code', [
|
||||
'installation_name' => (string) config('app.name', 'InvoiceShelf').' — '.parse_url((string) config('app.url'), PHP_URL_HOST),
|
||||
'invoiceshelf_version' => (string) Setting::getSetting('version'),
|
||||
'module_api_version' => (string) config('invoiceshelf.marketplace.module_api_version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'extensions' => collect(get_loaded_extensions())
|
||||
->map(fn (string $extension): string => 'ext-'.str_replace(' ', '-', strtolower($extension)))
|
||||
->filter(fn (string $extension): bool => preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $extension) === 1)
|
||||
->unique()
|
||||
->sort()->values()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function pollPairing(string $deviceCode): Response
|
||||
{
|
||||
return $this->request()->post(self::API_PREFIX.'/device/token', [
|
||||
'device_code' => $deviceCode,
|
||||
]);
|
||||
}
|
||||
|
||||
public function revokeInstallation(): Response
|
||||
{
|
||||
return $this->request()->delete(self::API_PREFIX.'/device');
|
||||
}
|
||||
|
||||
public function release(string $slug, string $version, string $channel): Response
|
||||
{
|
||||
return $this->request()->post(self::API_PREFIX."/modules/{$slug}/releases/{$version}/download", [
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed artifact URLs are intentionally fetched with a separate request:
|
||||
* marketplace credentials must never be sent to an object-storage host.
|
||||
*/
|
||||
public function artifact(string $url, string $destination): Response
|
||||
{
|
||||
if (! $this->allowsArtifactUrl($url)) {
|
||||
throw new RuntimeException('Marketplace returned an unsafe artifact URL.');
|
||||
}
|
||||
|
||||
return Http::withOptions(['verify' => true, 'allow_redirects' => false])
|
||||
->timeout(120)
|
||||
->sink($destination)
|
||||
->get($url);
|
||||
}
|
||||
|
||||
private function allowsArtifactUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts) || isset($parts['user'], $parts['pass']) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strtolower($parts['scheme']) === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$base = parse_url((string) config('invoiceshelf.base_url'));
|
||||
|
||||
return strtolower($parts['scheme']) === 'http'
|
||||
&& is_array($base)
|
||||
&& strtolower((string) ($base['scheme'] ?? '')) === 'http'
|
||||
&& strtolower($parts['host']) === strtolower((string) ($base['host'] ?? ''))
|
||||
&& ($parts['port'] ?? 80) === ($base['port'] ?? 80);
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
$request = Http::baseUrl(rtrim((string) config('invoiceshelf.base_url'), '/'))
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
->connectTimeout(10)
|
||||
->withOptions(['verify' => true, 'allow_redirects' => false])
|
||||
->withHeaders([
|
||||
'Referer' => url('/'),
|
||||
'invoiceshelf' => (string) Setting::getSetting('version'),
|
||||
]);
|
||||
|
||||
$credential = MarketplaceCredential::query()->latest('id')->first();
|
||||
|
||||
if ($credential !== null && ($credential->expires_at === null || $credential->expires_at->isFuture())) {
|
||||
$request = $request->withToken(Crypt::decryptString($credential->credential));
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketplace;
|
||||
|
||||
use App\Events\ModuleEnabledEvent;
|
||||
use App\Events\ModuleInstalledEvent;
|
||||
use App\Models\MarketplaceOperation;
|
||||
use App\Models\Module as InstalledModule;
|
||||
use App\Models\Setting;
|
||||
use Composer\Semver\Semver;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
class MarketplaceInstaller
|
||||
{
|
||||
public function __construct(private MarketplaceClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, operation_id?: int, error?: string}
|
||||
*/
|
||||
public function install(string $slug, string $version, string $channel): array
|
||||
{
|
||||
$operation = $this->acquireLease($slug, $version, $channel);
|
||||
if ($operation === null) {
|
||||
return ['success' => false, 'error' => 'Another marketplace installation is in progress.'];
|
||||
}
|
||||
|
||||
$workspace = null;
|
||||
|
||||
try {
|
||||
$release = $this->release($slug, $version, $channel);
|
||||
$manifest = $release['manifest'];
|
||||
$moduleName = $this->moduleName($manifest);
|
||||
$this->assertManifestIdentity($manifest, $slug, $version, $moduleName);
|
||||
$this->assertCompatible($manifest, $channel);
|
||||
|
||||
$installed = InstalledModule::query()->where('name', $moduleName)->first();
|
||||
if ($installed !== null && $installed->installed && version_compare($version, $installed->version, '<=')) {
|
||||
throw new RuntimeException('Downgrades and reinstalling the same release are not permitted.');
|
||||
}
|
||||
|
||||
$workspace = $this->workspace((string) $operation->id);
|
||||
$zipPath = $workspace.'/artifact.zip';
|
||||
$this->downloadArtifact($release['artifact'], $zipPath);
|
||||
$this->validateArtifact($zipPath, $release['artifact']);
|
||||
|
||||
$extracted = $workspace.'/extracted';
|
||||
$this->extractAndValidate($zipPath, $extracted, $slug, $version, $moduleName, $manifest);
|
||||
$this->assertSafeMigrations($extracted.'/'.$moduleName);
|
||||
|
||||
$previous = $installed?->only(['version', 'installed', 'enabled', 'state', 'last_error', 'last_failed_at']);
|
||||
$backup = $this->swap($extracted.'/'.$moduleName, $moduleName, (string) $operation->id);
|
||||
|
||||
try {
|
||||
ModuleRuntimeAutoloader::register($moduleName);
|
||||
$migrationPath = base_path("Modules/{$moduleName}/database/migrations");
|
||||
if (File::isDirectory($migrationPath)) {
|
||||
$exitCode = Artisan::call('migrate', [
|
||||
'--path' => $migrationPath,
|
||||
'--realpath' => true,
|
||||
'--force' => true,
|
||||
]);
|
||||
if ($exitCode !== 0) {
|
||||
throw new RuntimeException('Module migrations failed: '.trim(Artisan::output()));
|
||||
}
|
||||
}
|
||||
|
||||
$record = InstalledModule::query()->updateOrCreate(
|
||||
['name' => $moduleName],
|
||||
[
|
||||
'slug' => $slug,
|
||||
'version' => $version,
|
||||
'installed' => true,
|
||||
'enabled' => true,
|
||||
'state' => 'installed',
|
||||
'last_error' => null,
|
||||
'last_failed_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
Module::register();
|
||||
Module::find($moduleName)?->enable();
|
||||
Artisan::call('optimize:clear --no-interaction');
|
||||
Artisan::call('queue:restart --no-interaction');
|
||||
ModuleInstalledEvent::dispatch($record);
|
||||
ModuleEnabledEvent::dispatch($record);
|
||||
$this->finish($operation, 'completed');
|
||||
$this->clean($workspace, $backup);
|
||||
|
||||
return ['success' => true, 'operation_id' => $operation->id];
|
||||
} catch (Throwable $exception) {
|
||||
$this->restore($moduleName, $backup);
|
||||
$this->restoreDatabaseState($moduleName, $previous, $slug, $exception);
|
||||
throw $exception;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
if (is_string($workspace) && File::isDirectory($workspace)) {
|
||||
File::deleteDirectory($workspace);
|
||||
}
|
||||
$this->finish($operation, 'failed', $exception->getMessage());
|
||||
|
||||
return ['success' => false, 'operation_id' => $operation->id, 'error' => $exception->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{manifest: array<string, mixed>, artifact: array<string, mixed>} */
|
||||
private function release(string $slug, string $version, string $channel): array
|
||||
{
|
||||
$response = $this->client->release($slug, $version, $channel);
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Marketplace release request failed.');
|
||||
}
|
||||
|
||||
$body = $response->json();
|
||||
if (! is_array($body) || array_diff(array_keys($body), ['success', 'manifest', 'signature', 'key_id', 'artifact', 'release_state', 'yanked_reason']) !== []
|
||||
|| ($body['success'] ?? false) !== true || ! is_array($body['manifest'] ?? null)
|
||||
|| ! is_array($body['artifact'] ?? null) || ! is_string($body['signature'] ?? null) || ! is_string($body['key_id'] ?? null)) {
|
||||
throw new RuntimeException('Marketplace returned an invalid release response.');
|
||||
}
|
||||
|
||||
if (($body['release_state'] ?? null) !== 'published') {
|
||||
throw new RuntimeException('This release has been yanked and cannot be installed.');
|
||||
}
|
||||
|
||||
$this->assertReleaseManifest($body['manifest'], $body['key_id']);
|
||||
$this->assertEnvelopeArtifact($body['artifact']);
|
||||
|
||||
$signedArtifact = $body['manifest']['artifact'] ?? null;
|
||||
if (! is_array($signedArtifact)
|
||||
|| ($signedArtifact['sha256'] ?? null) !== ($body['artifact']['sha256'] ?? null)
|
||||
|| ($signedArtifact['bytes'] ?? null) !== ($body['artifact']['bytes'] ?? null)) {
|
||||
throw new RuntimeException('Release artifact integrity fields are not covered by the signed manifest.');
|
||||
}
|
||||
|
||||
$this->verifySignature($body['manifest'], $body['signature'], $body['key_id'] ?? null);
|
||||
|
||||
return ['manifest' => $body['manifest'], 'artifact' => $body['artifact']];
|
||||
}
|
||||
|
||||
private function verifySignature(array $manifest, string $signature, mixed $keyId): void
|
||||
{
|
||||
$signatureBytes = base64_decode($signature, true);
|
||||
if ($signatureBytes === false || strlen($signatureBytes) !== SODIUM_CRYPTO_SIGN_BYTES
|
||||
|| ! is_string($keyId) || ! function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
throw new RuntimeException('The release signature cannot be verified.');
|
||||
}
|
||||
|
||||
$keys = config('invoiceshelf.marketplace.public_keys', []);
|
||||
$keys = is_array($keys) ? $keys : [];
|
||||
if (! isset($keys[$keyId]) || ! is_string($keys[$keyId])) {
|
||||
throw new RuntimeException('The release uses an unknown signing key.');
|
||||
}
|
||||
|
||||
$payload = CanonicalJson::encode($manifest);
|
||||
$publicKey = base64_decode($keys[$keyId], true);
|
||||
if ($publicKey !== false && strlen($publicKey) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
|
||||
&& sodium_crypto_sign_verify_detached($signatureBytes, $payload, $publicKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('The release signature is invalid or was signed by an unpinned key.');
|
||||
}
|
||||
|
||||
private function assertCompatible(array $manifest, string $channel): void
|
||||
{
|
||||
if (($manifest['channel'] ?? null) !== $channel) {
|
||||
throw new RuntimeException('Release channel does not match the requested channel.');
|
||||
}
|
||||
|
||||
$compatibility = $manifest['compatibility'] ?? [];
|
||||
if (! is_array($compatibility)) {
|
||||
throw new RuntimeException('Release compatibility metadata is invalid.');
|
||||
}
|
||||
|
||||
$appVersion = (string) config('app.version', Setting::getSetting('version'));
|
||||
$minimum = $compatibility['invoiceshelf'] ?? null;
|
||||
if (is_string($minimum) && $minimum !== '' && ! $this->satisfiesConstraint($appVersion, $minimum)) {
|
||||
throw new RuntimeException('This module requires a newer InvoiceShelf version.');
|
||||
}
|
||||
|
||||
$php = $compatibility['php'] ?? null;
|
||||
if (is_string($php) && $php !== '' && ! $this->satisfiesConstraint(PHP_VERSION, $php)) {
|
||||
throw new RuntimeException('This module requires a newer PHP version.');
|
||||
}
|
||||
|
||||
$moduleApi = $compatibility['module_api'] ?? null;
|
||||
if (! is_string($moduleApi) || ! $this->satisfiesConstraint((string) config('invoiceshelf.marketplace.module_api_version'), $moduleApi)) {
|
||||
throw new RuntimeException('This module requires an unsupported module runtime API.');
|
||||
}
|
||||
|
||||
foreach (($compatibility['extensions'] ?? []) as $extension) {
|
||||
if (! is_string($extension) || ! str_starts_with($extension, 'ext-') || ! extension_loaded(substr($extension, 4))) {
|
||||
throw new RuntimeException('A required PHP extension is unavailable.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadArtifact(array $artifact, string $path): void
|
||||
{
|
||||
$url = $artifact['download_url'] ?? null;
|
||||
if (! is_string($url)) {
|
||||
throw new RuntimeException('Marketplace returned an unsafe artifact URL.');
|
||||
}
|
||||
if (! is_int($artifact['bytes'] ?? null) || $artifact['bytes'] > config('invoiceshelf.marketplace.max_zip_compressed_bytes')) {
|
||||
throw new RuntimeException('Module artifact exceeds the configured download limit.');
|
||||
}
|
||||
|
||||
$response = $this->client->artifact($url, $path);
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Module artifact download failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateArtifact(string $path, array $artifact): void
|
||||
{
|
||||
$expectedHash = $artifact['sha256'] ?? null;
|
||||
$expectedBytes = $artifact['bytes'] ?? null;
|
||||
if (! is_string($expectedHash) || preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1
|
||||
|| ! is_int($expectedBytes) || $expectedBytes < 1
|
||||
|| hash_file('sha256', $path) !== $expectedHash || filesize($path) !== $expectedBytes) {
|
||||
throw new RuntimeException('Module artifact integrity validation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertManifestIdentity(array $manifest, string $slug, string $version, string $moduleName): void
|
||||
{
|
||||
if (($manifest['slug'] ?? null) !== $slug || ($manifest['module_name'] ?? null) !== $moduleName || ($manifest['version'] ?? null) !== $version) {
|
||||
throw new RuntimeException('Release manifest does not match the requested module release.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertReleaseManifest(array $manifest, string $keyId): void
|
||||
{
|
||||
$expected = ['schema_version', 'slug', 'module_name', 'version', 'channel', 'publication', 'compatibility', 'artifact', 'key_id', 'source_commit', 'released_at'];
|
||||
if (array_diff(array_keys($manifest), $expected) !== [] || array_diff($expected, array_keys($manifest)) !== []
|
||||
|| ($manifest['schema_version'] ?? null) !== 1 || ($manifest['publication'] ?? null) !== 'published'
|
||||
|| ! is_string($manifest['slug'] ?? null) || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $manifest['slug']) !== 1
|
||||
|| ! is_string($manifest['module_name'] ?? null) || preg_match('/^[A-Z][A-Za-z0-9]*$/', $manifest['module_name']) !== 1
|
||||
|| ! is_string($manifest['version'] ?? null) || ! $this->isSemverVersion($manifest['version'])
|
||||
|| ! is_string($manifest['channel'] ?? null) || ! in_array($manifest['channel'], ['stable', 'insider'], true)
|
||||
|| ! is_string($manifest['key_id'] ?? null) || $manifest['key_id'] !== $keyId
|
||||
|| ! is_string($manifest['source_commit'] ?? null) || preg_match('/^[0-9a-f]{40}$/', $manifest['source_commit']) !== 1
|
||||
|| ! is_string($manifest['released_at'] ?? null) || strtotime($manifest['released_at']) === false) {
|
||||
throw new RuntimeException('Signed release manifest has an invalid schema.');
|
||||
}
|
||||
|
||||
if (($manifest['channel'] === 'stable' && str_contains($manifest['version'], '-'))
|
||||
|| ($manifest['channel'] === 'insider' && ! str_contains($manifest['version'], '-'))) {
|
||||
throw new RuntimeException('Signed release manifest has an invalid channel/version combination.');
|
||||
}
|
||||
|
||||
$compatibility = $manifest['compatibility'] ?? null;
|
||||
if (! is_array($compatibility) || array_diff(array_keys($compatibility), ['invoiceshelf', 'module_api', 'php', 'extensions']) !== []
|
||||
|| array_diff(['invoiceshelf', 'module_api', 'php', 'extensions'], array_keys($compatibility)) !== []
|
||||
|| ! is_array($compatibility['extensions']) || ! array_is_list($compatibility['extensions'])) {
|
||||
throw new RuntimeException('Signed release compatibility metadata has an invalid schema.');
|
||||
}
|
||||
foreach (['invoiceshelf', 'module_api', 'php'] as $field) {
|
||||
if (! is_string($compatibility[$field]) || ! $this->isSemverConstraint($compatibility[$field])) {
|
||||
throw new RuntimeException('Signed release compatibility constraint is invalid.');
|
||||
}
|
||||
}
|
||||
foreach ($compatibility['extensions'] as $extension) {
|
||||
if (! is_string($extension) || preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $extension) !== 1) {
|
||||
throw new RuntimeException('Signed release extension requirement is invalid.');
|
||||
}
|
||||
if (count(array_keys($compatibility['extensions'], $extension, true)) > 1) {
|
||||
throw new RuntimeException('Signed release extension requirements must not be duplicated.');
|
||||
}
|
||||
}
|
||||
$this->assertReleaseArtifact($manifest['artifact']);
|
||||
}
|
||||
|
||||
private function assertReleaseArtifact(mixed $artifact): void
|
||||
{
|
||||
if (! is_array($artifact) || array_diff(array_keys($artifact), ['sha256', 'bytes']) !== []
|
||||
|| array_diff(['sha256', 'bytes'], array_keys($artifact)) !== []
|
||||
|| ! is_string($artifact['sha256'] ?? null) || preg_match('/^[a-f0-9]{64}$/', $artifact['sha256']) !== 1
|
||||
|| ! is_int($artifact['bytes'] ?? null) || $artifact['bytes'] < 1) {
|
||||
throw new RuntimeException('Release artifact metadata has an invalid schema.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertEnvelopeArtifact(array $artifact): void
|
||||
{
|
||||
if (array_diff(array_keys($artifact), ['sha256', 'bytes', 'download_url', 'expires_at']) !== []
|
||||
|| array_diff(['sha256', 'bytes', 'download_url', 'expires_at'], array_keys($artifact)) !== []
|
||||
|| ! is_string($artifact['download_url'] ?? null) || filter_var($artifact['download_url'], FILTER_VALIDATE_URL) === false
|
||||
|| ! is_string($artifact['expires_at'] ?? null) || strtotime($artifact['expires_at']) === false
|
||||
|| strtotime($artifact['expires_at']) <= now()->getTimestamp()) {
|
||||
throw new RuntimeException('Release download envelope has an invalid artifact.');
|
||||
}
|
||||
|
||||
$this->assertReleaseArtifact([
|
||||
'sha256' => $artifact['sha256'] ?? null,
|
||||
'bytes' => $artifact['bytes'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function satisfiesConstraint(string $version, string $constraint): bool
|
||||
{
|
||||
try {
|
||||
return Semver::satisfies(ltrim($version, 'v'), $constraint);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function isSemverVersion(string $version): bool
|
||||
{
|
||||
return preg_match('/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/', $version) === 1;
|
||||
}
|
||||
|
||||
private function isSemverConstraint(string $constraint): bool
|
||||
{
|
||||
if ($constraint === '' || str_contains($constraint, '||') || str_contains($constraint, '*')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$version = '(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?';
|
||||
$part = '(?:\^|~|>=|<=|>|<|=)?v?'.$version;
|
||||
|
||||
return preg_match('/^'.$part.'(?:\s+'.$part.')*$/', $constraint) === 1;
|
||||
}
|
||||
|
||||
private function extractAndValidate(string $zipPath, string $destination, string $slug, string $version, string $moduleName, array $manifest): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new RuntimeException('Module artifact is not a valid ZIP archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
$limits = config('invoiceshelf.marketplace');
|
||||
if ($zip->numFiles > $limits['max_zip_entries']) {
|
||||
throw new RuntimeException('Module archive contains too many files.');
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
$root = null;
|
||||
$compressed = 0;
|
||||
$uncompressed = 0;
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$stat = $zip->statIndex($index);
|
||||
$name = is_array($stat) ? ($stat['name'] ?? null) : null;
|
||||
if (! is_string($name) || ! $this->safeZipPath($name)) {
|
||||
throw new RuntimeException('Module archive contains an unsafe path.');
|
||||
}
|
||||
$normal = rtrim($name, '/');
|
||||
if ($normal !== '' && isset($entries[$normal])) {
|
||||
throw new RuntimeException('Module archive contains duplicate paths.');
|
||||
}
|
||||
$entries[$normal] = $name;
|
||||
$parts = explode('/', $normal);
|
||||
$root ??= $parts[0] ?? null;
|
||||
if (($parts[0] ?? null) !== $root || $this->zipEntryIsSymlink($zip, $index)) {
|
||||
throw new RuntimeException('Module archive has an invalid root or symbolic link.');
|
||||
}
|
||||
$compressed += (int) ($stat['comp_size'] ?? 0);
|
||||
$uncompressed += (int) ($stat['size'] ?? 0);
|
||||
}
|
||||
|
||||
if ($root !== $moduleName || $compressed > $limits['max_zip_compressed_bytes'] || $uncompressed > $limits['max_zip_uncompressed_bytes']
|
||||
|| ($compressed > 0 && $uncompressed / $compressed > $limits['max_zip_compression_ratio'])) {
|
||||
throw new RuntimeException('Module archive exceeds limits or has an unexpected root directory.');
|
||||
}
|
||||
|
||||
foreach ($entries as $normal => $name) {
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$target = $destination.'/'.$normal;
|
||||
if (str_ends_with($name, '/')) {
|
||||
File::makeDirectory($target, 0755, true, true);
|
||||
|
||||
continue;
|
||||
}
|
||||
File::ensureDirectoryExists(dirname($target));
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Module archive entry cannot be read.');
|
||||
}
|
||||
file_put_contents($target, stream_get_contents($stream));
|
||||
fclose($stream);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
$moduleJson = $destination.'/'.$moduleName.'/module.json';
|
||||
$metadata = json_decode((string) File::get($moduleJson), true);
|
||||
if (! is_array($metadata) || ($metadata['schema_version'] ?? null) !== 1 || ($metadata['name'] ?? null) !== $moduleName || ! is_array($metadata['providers'] ?? null)
|
||||
|| ($manifest['slug'] ?? null) !== $slug || ($manifest['module_name'] ?? null) !== $moduleName || ($manifest['version'] ?? null) !== $version) {
|
||||
throw new RuntimeException('Module metadata does not match the signed release manifest.');
|
||||
}
|
||||
|
||||
if (($metadata['slug'] ?? null) !== $slug || ($metadata['version'] ?? null) !== $version
|
||||
|| ($metadata['compatibility'] ?? null) !== ($manifest['compatibility'] ?? null)) {
|
||||
throw new RuntimeException('Module package metadata does not match signed release compatibility.');
|
||||
}
|
||||
|
||||
$this->assertModuleManifest($metadata, $moduleName, $slug);
|
||||
|
||||
foreach ($metadata['providers'] as $provider) {
|
||||
if (! is_string($provider) || ! str_starts_with($provider, "Modules\\{$moduleName}\\")
|
||||
|| preg_match('/^Modules\\\\[A-Za-z][A-Za-z0-9]*(?:\\\\[A-Za-z][A-Za-z0-9]*)*$/', $provider) !== 1
|
||||
|| ! File::isFile($destination.'/'.$moduleName.'/app/'.str_replace('\\', '/', substr($provider, strlen("Modules\\{$moduleName}\\"))).'.php')) {
|
||||
throw new RuntimeException('Module has an invalid service provider declaration.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertDependencyPolicy($destination.'/'.$moduleName, $metadata);
|
||||
$this->assertAssetPolicy($destination.'/'.$moduleName, $metadata);
|
||||
}
|
||||
|
||||
private function assertDependencyPolicy(string $modulePath, array $metadata): void
|
||||
{
|
||||
if (($metadata['dependency_policy'] ?? null) !== 'host-provided-only' || ! is_array($metadata['module_dependencies'] ?? null)) {
|
||||
throw new RuntimeException('Module dependency policy is invalid.');
|
||||
}
|
||||
foreach ($metadata['module_dependencies'] as $dependencySlug => $constraint) {
|
||||
$dependency = is_string($dependencySlug)
|
||||
? InstalledModule::query()->where('slug', $dependencySlug)->where('enabled', true)->first()
|
||||
: null;
|
||||
if (! is_string($constraint) || $dependency === null || ! $this->satisfiesConstraint($dependency->version, $constraint)) {
|
||||
throw new RuntimeException('A required module dependency is not enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
$composer = $modulePath.'/composer.json';
|
||||
if (! File::exists($composer)) {
|
||||
throw new RuntimeException('Module archive does not contain composer.json.');
|
||||
}
|
||||
$data = json_decode((string) File::get($composer), true);
|
||||
if (! is_array($data) || array_is_list($data)
|
||||
|| ($data['name'] ?? null) !== "invoiceshelf/module-{$metadata['slug']}"
|
||||
|| ($data['license'] ?? null) !== 'AGPL-3.0-only'
|
||||
|| ! is_array($data['require'] ?? null) || array_is_list($data['require'])) {
|
||||
throw new RuntimeException('Module composer manifest is invalid.');
|
||||
}
|
||||
foreach ($data['require'] as $package => $constraint) {
|
||||
if (! is_string($package) || ! is_string($constraint)
|
||||
|| ($package !== 'php' && $package !== 'invoiceshelf/modules' && ! str_starts_with($package, 'ext-'))
|
||||
|| (in_array($package, ['php', 'invoiceshelf/modules'], true) && ! $this->isSemverConstraint($constraint))
|
||||
|| (str_starts_with($package, 'ext-') && $constraint !== '*' && ! $this->isSemverConstraint($constraint))) {
|
||||
throw new RuntimeException('Module packages may only depend on host-provided dependencies.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertModuleManifest(array $metadata, string $moduleName, string $slug): void
|
||||
{
|
||||
$expected = ['name', 'alias', 'description', 'keywords', 'priority', 'providers', 'aliases', 'files', 'requires', 'schema_version', 'slug', 'version', 'license', 'compatibility', 'module_dependencies', 'migration_policy', 'dependency_policy', 'assets'];
|
||||
if (array_diff(array_keys($metadata), $expected) !== [] || array_diff($expected, array_keys($metadata)) !== []
|
||||
|| ! is_string($metadata['alias'] ?? null) || preg_match('/^[a-z][a-z0-9_]*$/', $metadata['alias']) !== 1
|
||||
|| ! is_string($metadata['description'] ?? null) || ! is_int($metadata['priority'] ?? null) || $metadata['priority'] < 0
|
||||
|| ($metadata['license'] ?? null) !== 'AGPL-3.0-only'
|
||||
|| ! is_array($metadata['keywords'] ?? null) || ! array_is_list($metadata['keywords'])
|
||||
|| ! is_array($metadata['providers'] ?? null) || ! array_is_list($metadata['providers']) || $metadata['providers'] === []
|
||||
|| ! is_array($metadata['aliases'] ?? null) || (! empty($metadata['aliases']) && array_is_list($metadata['aliases']))
|
||||
|| ! is_array($metadata['requires'] ?? null) || (! empty($metadata['requires']) && array_is_list($metadata['requires']))
|
||||
|| ! is_array($metadata['files'] ?? null) || ! array_is_list($metadata['files'])
|
||||
|| ! is_array($metadata['assets'] ?? null) || ! array_is_list($metadata['assets'])
|
||||
|| ($metadata['migration_policy'] ?? null) !== 'forward-only' || ($metadata['dependency_policy'] ?? null) !== 'host-provided-only') {
|
||||
throw new RuntimeException('Module package manifest has an invalid schema.');
|
||||
}
|
||||
|
||||
foreach ($metadata['keywords'] as $keyword) {
|
||||
if (! is_string($keyword)) {
|
||||
throw new RuntimeException('Module package keywords must be strings.');
|
||||
}
|
||||
}
|
||||
if (count($metadata['providers']) !== count(array_unique($metadata['providers']))) {
|
||||
throw new RuntimeException('Module package providers must not be duplicated.');
|
||||
}
|
||||
foreach ($metadata['files'] as $file) {
|
||||
if (! is_string($file) || preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $file) !== 1
|
||||
|| str_contains($file, '..') || str_contains($file, '//')) {
|
||||
throw new RuntimeException('Module loader files must be local paths.');
|
||||
}
|
||||
}
|
||||
foreach ($metadata['module_dependencies'] as $dependencySlug => $constraint) {
|
||||
if (! is_string($dependencySlug) || $dependencySlug === $slug
|
||||
|| preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $dependencySlug) !== 1
|
||||
|| ! is_string($constraint) || ! $this->isSemverConstraint($constraint)) {
|
||||
throw new RuntimeException('Module dependency declaration is invalid.');
|
||||
}
|
||||
}
|
||||
if (count($metadata['assets']) !== count(array_unique($metadata['assets']))) {
|
||||
throw new RuntimeException('Module package assets must not be duplicated.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSafeMigrations(string $modulePath): void
|
||||
{
|
||||
$metadata = json_decode((string) File::get($modulePath.'/module.json'), true);
|
||||
if (($metadata['migration_policy'] ?? null) !== 'forward-only') {
|
||||
throw new RuntimeException('Module migrations must declare the forward-only policy.');
|
||||
}
|
||||
foreach (File::glob($modulePath.'/database/migrations/*.php') as $migration) {
|
||||
$source = (string) File::get($migration);
|
||||
if (preg_match('/\b(drop|rename|truncate|delete|update)\w*\s*\(/i', $source) === 1) {
|
||||
throw new RuntimeException('Module migrations must be forward-only additive migrations.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function safeZipPath(string $name): bool
|
||||
{
|
||||
return $name !== '' && ! str_contains($name, "\0") && ! str_starts_with($name, '/') && ! preg_match('/^[A-Za-z]:/', $name)
|
||||
&& ! str_contains($name, '\\') && ! array_intersect(['.', '..', ''], explode('/', $name)) && ! str_contains($name, '//');
|
||||
}
|
||||
|
||||
private function zipEntryIsSymlink(ZipArchive $zip, int $index): bool
|
||||
{
|
||||
$result = $zip->getExternalAttributesIndex($index, $opsys, $attributes);
|
||||
|
||||
return $result && (($attributes >> 16) & 0170000) === 0120000;
|
||||
}
|
||||
|
||||
private function assertAssetPolicy(string $modulePath, array $metadata): void
|
||||
{
|
||||
foreach (($metadata['assets'] ?? []) as $asset) {
|
||||
if (! is_string($asset) || preg_match('#^dist/[A-Za-z0-9][A-Za-z0-9._/-]*\.(?:js|css)$#', $asset) !== 1
|
||||
|| str_contains($asset, '..') || str_contains($asset, '//') || ! File::isFile($modulePath.'/'.$asset)) {
|
||||
throw new RuntimeException('Module assets must be local, path-contained files.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function moduleName(array $manifest): string
|
||||
{
|
||||
$name = $manifest['module_name'] ?? null;
|
||||
if (! is_string($name) || preg_match('/^[A-Z][A-Za-z0-9]*$/', $name) !== 1) {
|
||||
throw new RuntimeException('Release manifest has an invalid module name.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function workspace(string $id): string
|
||||
{
|
||||
$path = base_path("Modules/.staging/{$id}");
|
||||
File::ensureDirectoryExists($path);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function swap(string $source, string $name, string $operation): ?string
|
||||
{
|
||||
$target = base_path("Modules/{$name}");
|
||||
$backup = base_path("Modules/.backups/{$name}-{$operation}");
|
||||
File::ensureDirectoryExists(dirname($backup));
|
||||
if (File::exists($target) && ! rename($target, $backup)) {
|
||||
throw new RuntimeException('Could not back up the installed module.');
|
||||
}
|
||||
if (! rename($source, $target)) {
|
||||
if (File::exists($backup)) {
|
||||
rename($backup, $target);
|
||||
}
|
||||
throw new RuntimeException('Could not activate the staged module.');
|
||||
}
|
||||
|
||||
return File::exists($backup) ? $backup : null;
|
||||
}
|
||||
|
||||
private function restore(string $name, ?string $backup): void
|
||||
{
|
||||
$target = base_path("Modules/{$name}");
|
||||
if ($backup !== null && File::exists($backup)) {
|
||||
File::deleteDirectory($target);
|
||||
rename($backup, $target);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::deleteDirectory($target);
|
||||
}
|
||||
|
||||
private function restoreDatabaseState(string $name, ?array $previous, string $slug, Throwable $exception): void
|
||||
{
|
||||
if ($previous === null) {
|
||||
InstalledModule::query()->updateOrCreate(['name' => $name], [
|
||||
'slug' => $slug, 'version' => '0.0.0', 'installed' => false, 'enabled' => false, 'state' => 'failed',
|
||||
'last_error' => Str::limit($exception->getMessage(), 65000), 'last_failed_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
InstalledModule::query()->where('name', $name)->update([
|
||||
...$previous,
|
||||
'state' => 'failed', 'last_error' => Str::limit($exception->getMessage(), 65000), 'last_failed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function acquireLease(string $slug, string $version, string $channel): ?MarketplaceOperation
|
||||
{
|
||||
$lock = 'marketplace-install';
|
||||
MarketplaceOperation::query()->where('lock_name', $lock)->where('expires_at', '<', now())->update([
|
||||
'lock_name' => null,
|
||||
'status' => 'failed',
|
||||
'error' => 'Marketplace operation lease expired.',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
try {
|
||||
return MarketplaceOperation::query()->create([
|
||||
'lock_name' => $lock, 'slug' => $slug, 'version' => $version, 'channel' => $channel,
|
||||
'status' => 'running', 'started_at' => now(), 'expires_at' => now()->addSeconds((int) config('invoiceshelf.marketplace.lease_seconds')),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function finish(MarketplaceOperation $operation, string $status, ?string $error = null): void
|
||||
{
|
||||
$operation->update(['lock_name' => null, 'status' => $status, 'error' => $error, 'finished_at' => now(), 'expires_at' => now()]);
|
||||
}
|
||||
|
||||
private function clean(string $workspace, ?string $backup): void
|
||||
{
|
||||
File::deleteDirectory($workspace);
|
||||
if ($backup !== null) {
|
||||
File::deleteDirectory($backup);
|
||||
}
|
||||
foreach (File::directories(base_path('Modules/.staging')) as $directory) {
|
||||
if (filemtime($directory) < now()->subDay()->getTimestamp()) {
|
||||
File::deleteDirectory($directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketplace;
|
||||
|
||||
class ModuleRuntimeAutoloader
|
||||
{
|
||||
/** @var array<string, true> */
|
||||
private static array $registered = [];
|
||||
|
||||
/**
|
||||
* Register module PSR-4 prefixes before Laravel registers package providers.
|
||||
*
|
||||
* This intentionally uses native filesystem functions because bootstrap/app.php
|
||||
* calls it before the service container and facades exist.
|
||||
*/
|
||||
public static function registerInstalledModules(?string $modulesPath = null): void
|
||||
{
|
||||
$modulesPath ??= dirname(__DIR__, 3).'/Modules';
|
||||
|
||||
if (! is_dir($modulesPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directories = glob(rtrim($modulesPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'*', GLOB_ONLYDIR) ?: [];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$name = basename($directory);
|
||||
|
||||
if (preg_match('/^[A-Z][A-Za-z0-9]*$/', $name) === 1 && is_file($directory.'/module.json')) {
|
||||
self::register($name, $modulesPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function register(string $name, ?string $modulesPath = null): void
|
||||
{
|
||||
$modulesPath ??= dirname(__DIR__, 3).'/Modules';
|
||||
$prefix = "Modules\\{$name}\\";
|
||||
$base = rtrim($modulesPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR;
|
||||
$registration = $prefix.$base;
|
||||
|
||||
if (isset(self::$registered[$registration])) {
|
||||
return;
|
||||
}
|
||||
|
||||
spl_autoload_register(static function (string $class) use ($prefix, $base): void {
|
||||
if (! str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
if ($relative === false || str_contains($relative, '..')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $base.str_replace('\\', '/', $relative).'.php';
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
});
|
||||
|
||||
self::$registered[$registration] = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Module;
|
||||
|
||||
class ModuleAssetVersion
|
||||
{
|
||||
public const HASH_LENGTH = 12;
|
||||
|
||||
public static function forPath(string $path): ?string
|
||||
{
|
||||
$hash = is_file($path) ? hash_file('sha256', $path) : false;
|
||||
|
||||
return is_string($hash) ? substr($hash, 0, self::HASH_LENGTH) : null;
|
||||
}
|
||||
|
||||
public static function forContents(string $contents): string
|
||||
{
|
||||
return substr(hash('sha256', $contents), 0, self::HASH_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -5,235 +5,16 @@ namespace App\Support\Module;
|
||||
use App\Events\ModuleEnabledEvent;
|
||||
use App\Events\ModuleInstalledEvent;
|
||||
use App\Models\Module as ModelsModule;
|
||||
use App\Models\Setting;
|
||||
use App\Traits\SiteApi;
|
||||
use Artisan;
|
||||
use File;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
use ZipArchive;
|
||||
|
||||
class ModuleInstaller
|
||||
{
|
||||
use SiteApi;
|
||||
|
||||
private static function marketplaceToken(): ?string
|
||||
{
|
||||
$token = Setting::getSetting('api_token');
|
||||
|
||||
if (! is_string($token) || trim($token) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private static function decodeMarketplaceJson($response): array
|
||||
{
|
||||
if ($response instanceof RequestException || ! $response) {
|
||||
return [
|
||||
'status' => 0,
|
||||
'body' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$body = $response->getBody()->getContents();
|
||||
|
||||
return [
|
||||
'status' => $response->getStatusCode(),
|
||||
'body' => $body !== '' ? json_decode($body) : null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getModules(): array
|
||||
{
|
||||
$url = env('APP_ENV') === 'development'
|
||||
? 'api/marketplace/modules?is_dev=1'
|
||||
: 'api/marketplace/modules';
|
||||
|
||||
$token = static::marketplaceToken();
|
||||
$decoded = static::decodeMarketplaceJson(
|
||||
static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token)
|
||||
);
|
||||
|
||||
if ($decoded['status'] === 401 && $token !== null) {
|
||||
$decoded = static::decodeMarketplaceJson(
|
||||
static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null)
|
||||
);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public static function getModule(string $module): array
|
||||
{
|
||||
$url = env('APP_ENV') === 'development'
|
||||
? 'api/marketplace/modules/'.$module.'?is_dev=1'
|
||||
: 'api/marketplace/modules/'.$module;
|
||||
|
||||
$token = static::marketplaceToken();
|
||||
$decoded = static::decodeMarketplaceJson(
|
||||
static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token)
|
||||
);
|
||||
|
||||
if ($decoded['status'] === 401 && $token !== null) {
|
||||
$decoded = static::decodeMarketplaceJson(
|
||||
static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null)
|
||||
);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public static function upload($request): string
|
||||
{
|
||||
$tempDir = storage_path('app/temp-'.md5(mt_rand()));
|
||||
|
||||
if (! File::isDirectory($tempDir)) {
|
||||
File::makeDirectory($tempDir);
|
||||
}
|
||||
|
||||
return $request->file('avatar')->storeAs(
|
||||
'temp-'.md5(mt_rand()),
|
||||
$request->module.'.zip',
|
||||
'local'
|
||||
);
|
||||
}
|
||||
|
||||
public static function download(string $slug, string $version, ?string $checksumSha256 = null): array|bool
|
||||
{
|
||||
$data = null;
|
||||
$path = null;
|
||||
|
||||
$url = env('APP_ENV') === 'development'
|
||||
? "api/marketplace/modules/file/{$slug}?version={$version}&is_dev=1"
|
||||
: "api/marketplace/modules/file/{$slug}?version={$version}";
|
||||
|
||||
$token = static::marketplaceToken();
|
||||
$response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token);
|
||||
|
||||
if ($response instanceof RequestException) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Download Exception',
|
||||
'data' => [
|
||||
'path' => $path,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($response && $response->getStatusCode() === 401 && $token !== null) {
|
||||
$response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null);
|
||||
}
|
||||
|
||||
if ($response instanceof RequestException || ! $response) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Download Exception',
|
||||
];
|
||||
}
|
||||
|
||||
if ($response && $response->getStatusCode() !== 200) {
|
||||
$decoded = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $decoded['error'] ?? 'Module download failed',
|
||||
];
|
||||
}
|
||||
|
||||
if ($response && $response->getStatusCode() === 200) {
|
||||
$data = $response->getBody()->getContents();
|
||||
}
|
||||
|
||||
$tempDir = storage_path('app/temp-'.md5(mt_rand()));
|
||||
|
||||
if (! File::isDirectory($tempDir)) {
|
||||
File::makeDirectory($tempDir);
|
||||
}
|
||||
|
||||
$zipFilePath = $tempDir.'/upload.zip';
|
||||
$uploaded = is_int(file_put_contents($zipFilePath, $data));
|
||||
|
||||
if (! $uploaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($checksumSha256 && hash_file('sha256', $zipFilePath) !== $checksumSha256) {
|
||||
File::delete($zipFilePath);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Checksum verification failed',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => $zipFilePath,
|
||||
];
|
||||
}
|
||||
|
||||
public static function unzip($module, $zipFilePath): string
|
||||
{
|
||||
if (! file_exists($zipFilePath)) {
|
||||
throw new \Exception('Zip file not found');
|
||||
}
|
||||
|
||||
$tempExtractDir = storage_path('app/temp2-'.md5(mt_rand()));
|
||||
|
||||
if (! File::isDirectory($tempExtractDir)) {
|
||||
File::makeDirectory($tempExtractDir);
|
||||
}
|
||||
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($zipFilePath)) {
|
||||
$zip->extractTo($tempExtractDir);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
File::delete($zipFilePath);
|
||||
|
||||
return $tempExtractDir;
|
||||
}
|
||||
|
||||
public static function copyFiles($module, $tempExtractDir): bool
|
||||
{
|
||||
if (! File::isDirectory(base_path('Modules'))) {
|
||||
File::makeDirectory(base_path('Modules'));
|
||||
}
|
||||
|
||||
if (File::isDirectory(base_path('Modules').'/'.$module)) {
|
||||
File::deleteDirectory(base_path('Modules').'/'.$module);
|
||||
}
|
||||
|
||||
if (! File::copyDirectory($tempExtractDir, base_path('Modules').'/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File::deleteDirectory($tempExtractDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function deleteFiles($json): bool
|
||||
{
|
||||
$files = json_decode($json);
|
||||
|
||||
foreach ($files as $file) {
|
||||
File::delete(base_path($file));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function complete($module, $version): bool
|
||||
{
|
||||
Module::register();
|
||||
|
||||
Artisan::call("module:migrate $module --force");
|
||||
Artisan::call("module:seed $module --force");
|
||||
Artisan::call("module:enable $module");
|
||||
|
||||
$module = ModelsModule::updateOrCreate(
|
||||
@@ -246,17 +27,4 @@ class ModuleInstaller
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function checkToken(string $token)
|
||||
{
|
||||
$url = 'api/marketplace/ping';
|
||||
$normalizedToken = trim($token) !== '' ? $token : null;
|
||||
$response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $normalizedToken);
|
||||
|
||||
if ($response && $response->getStatusCode() === 200) {
|
||||
return response()->json(json_decode($response->getBody()->getContents()));
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'invalid_token']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ trait SiteApi
|
||||
{
|
||||
protected static function getRemote($url, $data = [], $token = null)
|
||||
{
|
||||
$client = new Client(['verify' => false, 'base_uri' => config('invoiceshelf.base_url').'/']);
|
||||
$client = new Client(['verify' => true, 'base_uri' => config('invoiceshelf.base_url').'/']);
|
||||
|
||||
$headers['headers'] = [
|
||||
'Accept' => 'application/json',
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Http\Middleware\TrimStrings;
|
||||
use App\Http\Middleware\TrustProxies;
|
||||
use App\Http\Middleware\UseInstallWizardTokenAuth;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Services\Marketplace\ModuleRuntimeAutoloader;
|
||||
use Illuminate\Auth\Middleware\Authorize;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
@@ -32,6 +33,11 @@ use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Lavary\Menu\ServiceProvider;
|
||||
|
||||
// Marketplace modules are installed after Composer's autoload map is built.
|
||||
// Register their app/ prefixes before package discovery loads enabled module
|
||||
// service providers during application bootstrap.
|
||||
ModuleRuntimeAutoloader::registerInstalledModules(dirname(__DIR__).'/Modules');
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withProviders([
|
||||
ServiceProvider::class,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"framework",
|
||||
"laravel"
|
||||
],
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
|
||||
+34
-3
@@ -44,15 +44,46 @@ return [
|
||||
/*
|
||||
* Marketplace and updater base URL.
|
||||
*
|
||||
* The marketplace client (App\Support\Module\ModuleInstaller) and the
|
||||
* updater (App\Support\Update\Updater) both build their HTTP client base
|
||||
* URI from this value via App\Traits\SiteApi::getRemote(). Override via
|
||||
* The marketplace client (App\Services\Marketplace\MarketplaceClient) and
|
||||
* updater (App\Support\Update\Updater) both use this value as their HTTP
|
||||
* base URI (the updater via App\Traits\SiteApi::getRemote()). Override via
|
||||
* INVOICESHELF_BASE_URL in .env to point a self-hosted instance or local
|
||||
* dev environment at a non-production marketplace (e.g. a local checkout
|
||||
* of the invoiceshelf/website repo).
|
||||
*/
|
||||
'base_url' => env('INVOICESHELF_BASE_URL', 'https://invoiceshelf.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Secure marketplace
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Release manifests are signed by the marketplace. Keep production signing
|
||||
| keys here rather than accepting a key supplied by a catalogue response.
|
||||
| Values are base64 encoded Ed25519 public keys (32 byte raw keys). The
|
||||
| official key is a built-in trust anchor; MARKETPLACE_PUBLIC_KEYS can add
|
||||
| keys for rotation or replace an existing key ID during an emergency roll.
|
||||
|
|
||||
*/
|
||||
'marketplace' => [
|
||||
'channel' => env('MARKETPLACE_CHANNEL', 'stable'),
|
||||
'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.0.0'),
|
||||
// JSON object: {"key-id":"base64-ed25519-public-key"}. Keys add to
|
||||
// (or replace values in) the built-in pinned map. Key identity is part
|
||||
// of the signed release and must match this trusted map.
|
||||
'public_keys' => array_replace(
|
||||
[
|
||||
'official-modules-2026-01' => 'sIDGuOAaMVzPv9I/GPbWp9ci5aUI5HcM5rZ0tKxW6dc=',
|
||||
],
|
||||
json_decode((string) env('MARKETPLACE_PUBLIC_KEYS', '{}'), true) ?: [],
|
||||
),
|
||||
'max_zip_entries' => (int) env('MARKETPLACE_MAX_ZIP_ENTRIES', 10000),
|
||||
'max_zip_compressed_bytes' => (int) env('MARKETPLACE_MAX_ZIP_COMPRESSED_BYTES', 134217728),
|
||||
'max_zip_uncompressed_bytes' => (int) env('MARKETPLACE_MAX_ZIP_UNCOMPRESSED_BYTES', 256000000),
|
||||
'max_zip_compression_ratio' => (int) env('MARKETPLACE_MAX_ZIP_COMPRESSION_RATIO', 200),
|
||||
'lease_seconds' => (int) env('MARKETPLACE_LEASE_SECONDS', 900),
|
||||
],
|
||||
|
||||
/*
|
||||
* Whether the app runs inside the official Docker image. The image's
|
||||
* docker/production/inject.sh sets CONTAINERIZED=true in .env at startup.
|
||||
|
||||
+9
-5
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Marketplace\DatabaseActivator;
|
||||
use Nwidart\Modules\Activators\FileActivator;
|
||||
use Nwidart\Modules\Providers\ConsoleServiceProvider;
|
||||
|
||||
@@ -213,12 +214,15 @@ return [
|
||||
| Activators
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| InvoiceShelf-specific override: keep the statuses file under storage/app/
|
||||
| so existing installations don't lose track of which modules are enabled
|
||||
| when this config is republished. Upstream v13 defaults to base_path() but
|
||||
| the v3 ModuleInstaller already writes here.
|
||||
| Runtime activation uses the database activator above. The file activator
|
||||
| remains only as a backwards-compatible fallback before the database is
|
||||
| available during application bootstrap. Keep its legacy status file under
|
||||
| storage/app rather than the application root.
|
||||
*/
|
||||
'activators' => [
|
||||
'database' => [
|
||||
'class' => DatabaseActivator::class,
|
||||
],
|
||||
'file' => [
|
||||
'class' => FileActivator::class,
|
||||
'statuses-file' => base_path('storage/app/modules_statuses.json'),
|
||||
@@ -227,7 +231,7 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'activator' => 'file',
|
||||
'activator' => 'database',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('marketplace_credentials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Encrypted opaque installation token; never an account token.
|
||||
$table->text('credential');
|
||||
$table->string('device_id')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('paired_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('marketplace_operations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('lock_name')->nullable()->unique();
|
||||
$table->string('slug')->nullable();
|
||||
$table->string('version')->nullable();
|
||||
$table->string('channel')->nullable();
|
||||
$table->string('status')->default('running');
|
||||
$table->text('error')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('modules', function (Blueprint $table) {
|
||||
$table->unique('name');
|
||||
$table->string('slug')->nullable()->index();
|
||||
$table->string('state')->default('installed')->index();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamp('last_failed_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('modules', function (Blueprint $table) {
|
||||
$table->dropUnique(['name']);
|
||||
$table->dropColumn(['slug', 'state', 'last_error', 'last_failed_at']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('marketplace_operations');
|
||||
Schema::dropIfExists('marketplace_credentials');
|
||||
}
|
||||
};
|
||||
@@ -48,6 +48,7 @@ services:
|
||||
- SANCTUM_STATEFUL_DOMAINS=localhost:8090
|
||||
volumes:
|
||||
- invoiceshelf_storage:/var/www/html/storage/
|
||||
- invoiceshelf_modules:/var/www/html/Modules
|
||||
ports:
|
||||
- "8090:8080" # 8090 is the public port.
|
||||
networks:
|
||||
@@ -62,3 +63,4 @@ networks:
|
||||
volumes:
|
||||
invoiceshelf_mysql:
|
||||
invoiceshelf_storage:
|
||||
invoiceshelf_modules:
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
- "8090:8080" # 8090 is the public port.
|
||||
volumes:
|
||||
- invoiceshelf_storage:/var/www/html/storage
|
||||
- invoiceshelf_modules:/var/www/html/Modules
|
||||
networks:
|
||||
- invoiceshelf
|
||||
environment:
|
||||
@@ -32,3 +33,4 @@ networks:
|
||||
invoiceshelf:
|
||||
volumes:
|
||||
invoiceshelf_storage:
|
||||
invoiceshelf_modules:
|
||||
|
||||
+1
-180
@@ -15750,129 +15750,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/helloworlds": {
|
||||
"get": {
|
||||
"operationId": "helloworld.index",
|
||||
"summary": "Display a listing of the resource",
|
||||
"tags": [
|
||||
"HelloWorld"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "helloworld.store",
|
||||
"summary": "Store a newly created resource in storage",
|
||||
"tags": [
|
||||
"HelloWorld"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/helloworlds/{id}": {
|
||||
"get": {
|
||||
"operationId": "helloworld.show",
|
||||
"summary": "Show the specified resource",
|
||||
"tags": [
|
||||
"HelloWorld"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "helloworld.update",
|
||||
"summary": "Update the specified resource in storage",
|
||||
"tags": [
|
||||
"HelloWorld"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "helloworld.destroy",
|
||||
"summary": "Remove the specified resource from storage",
|
||||
"tags": [
|
||||
"HelloWorld"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/company-invitations": {
|
||||
"get": {
|
||||
"operationId": "company-invitations.index",
|
||||
@@ -18857,62 +18734,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/modules/check": {
|
||||
"get": {
|
||||
"operationId": "modules.checkToken",
|
||||
"tags": [
|
||||
"Modules"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "company",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"description": "ID of the company the request operates on (multi-tenancy).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"const": "invalid_token"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/AuthorizationException"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/modules/{module}": {
|
||||
"get": {
|
||||
"operationId": "modules.show",
|
||||
@@ -32696,4 +32517,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,12 +169,8 @@ export const API = {
|
||||
|
||||
// Modules
|
||||
MODULES: '/api/v1/modules',
|
||||
MODULES_CHECK: '/api/v1/modules/check',
|
||||
MODULES_DOWNLOAD: '/api/v1/modules/download',
|
||||
MODULES_UPLOAD: '/api/v1/modules/upload',
|
||||
MODULES_UNZIP: '/api/v1/modules/unzip',
|
||||
MODULES_COPY: '/api/v1/modules/copy',
|
||||
MODULES_COMPLETE: '/api/v1/modules/complete',
|
||||
MODULES_INSTALL: '/api/v1/modules/install',
|
||||
MODULES_PAIRING: '/api/v1/modules/pairing',
|
||||
|
||||
// Self Update
|
||||
CHECK_UPDATE: '/api/v1/check/update',
|
||||
|
||||
@@ -104,7 +104,6 @@ export type {
|
||||
ActiveProviderResponse,
|
||||
Module,
|
||||
ModuleInstallPayload,
|
||||
ModuleCheckResponse,
|
||||
Backup,
|
||||
BackupListResponse,
|
||||
CreateBackupPayload,
|
||||
|
||||
@@ -52,7 +52,7 @@ export type { CreateTaxTypePayload } from './tax-type.service'
|
||||
export type { CustomFieldListParams, CreateCustomFieldPayload } from './custom-field.service'
|
||||
export type { CreateNotePayload } from './note.service'
|
||||
export type { CreateExchangeRateProviderPayload, BulkUpdatePayload, ExchangeRateResponse, ActiveProviderResponse } from './exchange-rate.service'
|
||||
export type { Module, ModuleInstallPayload, ModuleCheckResponse } from './module.service'
|
||||
export type { Module, ModuleInstallPayload } from './module.service'
|
||||
export type { Backup, BackupListResponse, CreateBackupPayload, DeleteBackupParams } from './backup.service'
|
||||
export type { MailConfig, CompanyMailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config'
|
||||
export type { PdfConfig, PdfConfigResponse, PdfDriver, DomPdfConfig, GotenbergConfig } from './pdf.service'
|
||||
|
||||
@@ -3,11 +3,21 @@ import { API } from '../endpoints'
|
||||
import type { ApiResponse } from '@/scripts/types/api'
|
||||
import type { Module } from '@/scripts/types/domain/module'
|
||||
|
||||
export interface ModuleCheckResponse {
|
||||
error?: string
|
||||
success?: boolean
|
||||
authenticated?: boolean
|
||||
premium?: boolean
|
||||
export type { Module } from '@/scripts/types/domain/module'
|
||||
|
||||
export interface MarketplacePairingStatus {
|
||||
paired: boolean
|
||||
expired: boolean
|
||||
paired_at: string | null
|
||||
}
|
||||
|
||||
export interface MarketplacePairingCode {
|
||||
device_code: string
|
||||
user_code: string | null
|
||||
verification_uri: string | null
|
||||
verification_uri_complete: string | null
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface ModuleDetailMeta {
|
||||
@@ -16,10 +26,8 @@ export interface ModuleDetailMeta {
|
||||
|
||||
export interface ModuleInstallPayload {
|
||||
slug: string
|
||||
module_name: string
|
||||
version: string
|
||||
checksum_sha256?: string | null
|
||||
path?: string
|
||||
channel?: 'stable' | 'insider'
|
||||
}
|
||||
|
||||
export interface ModuleDetailResponse {
|
||||
@@ -38,8 +46,23 @@ export const moduleService = {
|
||||
return data
|
||||
},
|
||||
|
||||
async checkToken(apiToken: string): Promise<ModuleCheckResponse> {
|
||||
const { data } = await client.get(`${API.MODULES_CHECK}?api_token=${apiToken}`)
|
||||
async pairingStatus(): Promise<MarketplacePairingStatus> {
|
||||
const { data } = await client.get(API.MODULES_PAIRING)
|
||||
return data
|
||||
},
|
||||
|
||||
async startPairing(): Promise<MarketplacePairingCode> {
|
||||
const { data } = await client.post(`${API.MODULES_PAIRING}/start`)
|
||||
return data
|
||||
},
|
||||
|
||||
async pollPairing(): Promise<{ status: 'pending' | 'paired' }> {
|
||||
const { data } = await client.post(`${API.MODULES_PAIRING}/poll`)
|
||||
return data
|
||||
},
|
||||
|
||||
async disconnectMarketplace(): Promise<{ success: boolean }> {
|
||||
const { data } = await client.delete(API.MODULES_PAIRING)
|
||||
return data
|
||||
},
|
||||
|
||||
@@ -53,29 +76,8 @@ export const moduleService = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Installation flow
|
||||
async download(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.MODULES_DOWNLOAD, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async upload(payload: FormData): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.MODULES_UPLOAD, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async unzip(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.MODULES_UNZIP, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async copy(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.MODULES_COPY, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async complete(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.MODULES_COMPLETE, payload)
|
||||
async install(payload: ModuleInstallPayload): Promise<{ success: boolean; error?: string }> {
|
||||
const { data } = await client.post(API.MODULES_INSTALL, payload)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { moduleService } from '../../../api/services/module.service'
|
||||
import type { Module } from '../../../types/domain/module'
|
||||
import type {
|
||||
Module,
|
||||
} from '../../../types/domain/module'
|
||||
import type {
|
||||
ModuleCheckResponse,
|
||||
MarketplacePairingCode,
|
||||
MarketplacePairingStatus,
|
||||
ModuleDetailResponse,
|
||||
ModuleInstallPayload,
|
||||
} from '../../../api/services/module.service'
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------
|
||||
export type { ModuleDetailResponse, ModuleDetailMeta } from '../../../api/services/module.service'
|
||||
|
||||
export interface InstallationStep {
|
||||
translationKey: string
|
||||
@@ -21,22 +18,10 @@ export interface InstallationStep {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Store
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export interface ModuleState {
|
||||
currentModule: ModuleDetailResponse | null
|
||||
modules: Module[]
|
||||
apiToken: string | null
|
||||
currentUser: {
|
||||
api_token: string | null
|
||||
}
|
||||
marketplaceStatus: {
|
||||
authenticated: boolean
|
||||
premium: boolean
|
||||
invalidToken: boolean
|
||||
}
|
||||
marketplacePairing: MarketplacePairingStatus | null
|
||||
enableModules: string[]
|
||||
}
|
||||
|
||||
@@ -44,24 +29,13 @@ export const useModuleStore = defineStore('modules', {
|
||||
state: (): ModuleState => ({
|
||||
currentModule: null,
|
||||
modules: [],
|
||||
apiToken: null,
|
||||
currentUser: {
|
||||
api_token: null,
|
||||
},
|
||||
marketplaceStatus: {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
},
|
||||
marketplacePairing: null,
|
||||
enableModules: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
salesTaxUSEnabled: (state): boolean =>
|
||||
state.enableModules.includes('SalesTaxUS'),
|
||||
|
||||
installedModules: (state): Module[] =>
|
||||
state.modules.filter((m) => m.installed),
|
||||
salesTaxUSEnabled: (state): boolean => state.enableModules.includes('SalesTaxUS'),
|
||||
installedModules: (state): Module[] => state.modules.filter((m) => m.installed),
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -76,27 +50,25 @@ export const useModuleStore = defineStore('modules', {
|
||||
return response
|
||||
},
|
||||
|
||||
async checkApiToken(token: string): Promise<ModuleCheckResponse> {
|
||||
const response = await moduleService.checkToken(token)
|
||||
this.marketplaceStatus = {
|
||||
authenticated: response.authenticated ?? false,
|
||||
premium: response.premium ?? false,
|
||||
invalidToken: response.error === 'invalid_token',
|
||||
}
|
||||
async fetchMarketplacePairing(): Promise<MarketplacePairingStatus> {
|
||||
const response = await moduleService.pairingStatus()
|
||||
this.marketplacePairing = response
|
||||
return response
|
||||
},
|
||||
|
||||
setApiToken(token: string | null): void {
|
||||
this.apiToken = token
|
||||
this.currentUser.api_token = token
|
||||
async startMarketplacePairing(): Promise<MarketplacePairingCode> {
|
||||
return moduleService.startPairing()
|
||||
},
|
||||
|
||||
clearMarketplaceStatus(): void {
|
||||
this.marketplaceStatus = {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
}
|
||||
async pollMarketplacePairing(): Promise<{ status: 'pending' | 'paired' }> {
|
||||
const response = await moduleService.pollPairing()
|
||||
if (response.status === 'paired') await this.fetchMarketplacePairing()
|
||||
return response
|
||||
},
|
||||
|
||||
async disconnectMarketplace(): Promise<void> {
|
||||
await moduleService.disconnectMarketplace()
|
||||
this.marketplacePairing = { paired: false, expired: false, paired_at: null }
|
||||
},
|
||||
|
||||
async disableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||
@@ -111,99 +83,30 @@ export const useModuleStore = defineStore('modules', {
|
||||
payload: ModuleInstallPayload,
|
||||
onStepUpdate?: (step: InstallationStep) => void,
|
||||
): Promise<boolean> {
|
||||
const steps: InstallationStep[] = [
|
||||
{
|
||||
translationKey: 'modules.download_zip_file',
|
||||
stepUrl: '/api/v1/modules/download',
|
||||
time: null,
|
||||
started: false,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
translationKey: 'modules.unzipping_package',
|
||||
stepUrl: '/api/v1/modules/unzip',
|
||||
time: null,
|
||||
started: false,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
translationKey: 'modules.copying_files',
|
||||
stepUrl: '/api/v1/modules/copy',
|
||||
time: null,
|
||||
started: false,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
translationKey: 'modules.completing_installation',
|
||||
stepUrl: '/api/v1/modules/complete',
|
||||
time: null,
|
||||
started: false,
|
||||
completed: false,
|
||||
},
|
||||
]
|
||||
|
||||
let path: string | null = null
|
||||
|
||||
for (const step of steps) {
|
||||
step.started = true
|
||||
onStepUpdate?.(step)
|
||||
|
||||
try {
|
||||
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
|
||||
'/api/v1/modules/download': () =>
|
||||
moduleService.download({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/unzip': () =>
|
||||
moduleService.unzip({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/copy': () =>
|
||||
moduleService.copy({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/complete': () =>
|
||||
moduleService.complete({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
}
|
||||
|
||||
const result = await stepFns[step.stepUrl]()
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
|
||||
if ((result as Record<string, unknown>).path) {
|
||||
path = (result as Record<string, unknown>).path as string
|
||||
}
|
||||
|
||||
if (!(result as Record<string, unknown>).success) {
|
||||
const message = (result as Record<string, unknown>).error
|
||||
if (typeof message === 'string') {
|
||||
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||
useNotificationStore().showNotification({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||
useNotificationStore().showNotification({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : 'Module installation failed',
|
||||
})
|
||||
return false
|
||||
}
|
||||
const step: InstallationStep = {
|
||||
translationKey: 'modules.completing_installation',
|
||||
stepUrl: '/api/v1/modules/install',
|
||||
time: null,
|
||||
started: true,
|
||||
completed: false,
|
||||
}
|
||||
onStepUpdate?.(step)
|
||||
|
||||
return true
|
||||
try {
|
||||
const response = await moduleService.install(payload)
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
return response.success
|
||||
} catch (err: unknown) {
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||
useNotificationStore().showNotification({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : 'Module installation failed',
|
||||
})
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="rounded-xl border border-line-default bg-surface-secondary p-6">
|
||||
<!-- Not purchased -->
|
||||
<template v-if="!moduleData.purchased">
|
||||
<a :href="buyLink" target="_blank">
|
||||
<a :href="buyLink" target="_blank" rel="noopener">
|
||||
<BaseButton size="lg" class="w-full flex items-center justify-center">
|
||||
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
|
||||
{{ $t('modules.buy_now') }}
|
||||
@@ -413,7 +413,7 @@ const displayImages = computed<Array<{ url: string }>>(() => {
|
||||
})
|
||||
|
||||
const buyLink = computed<string>(() => {
|
||||
return `/modules/${moduleData.value?.slug ?? ''}`
|
||||
return moduleData.value?.purchase_url ?? '#'
|
||||
})
|
||||
|
||||
watch(() => route.params.slug, () => {
|
||||
@@ -440,7 +440,7 @@ async function loadData(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleInstall(): Promise<void> {
|
||||
if (!moduleData.value) return
|
||||
if (!moduleData.value?.latest_module_version) return
|
||||
|
||||
installationSteps.length = 0
|
||||
isInstalling.value = true
|
||||
@@ -448,9 +448,7 @@ async function handleInstall(): Promise<void> {
|
||||
const success = await moduleStore.installModule(
|
||||
{
|
||||
slug: moduleData.value.slug,
|
||||
module_name: moduleData.value.module_name,
|
||||
version: moduleData.value.latest_module_version,
|
||||
checksum_sha256: moduleData.value.latest_module_checksum_sha256,
|
||||
},
|
||||
(step) => {
|
||||
const existing = installationSteps.find(
|
||||
|
||||
@@ -10,58 +10,26 @@
|
||||
<BaseCard class="mt-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h6 class="text-heading text-lg font-medium">Marketplace Access</h6>
|
||||
<h6 class="text-heading text-lg font-medium">Marketplace access</h6>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Public modules are always available. Add your marketplace token to unlock premium modules tied to your website subscription.
|
||||
Pair this InvoiceShelf instance with your marketplace account. The device credential stays encrypted on this server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-medium"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid mt-6 lg:grid-cols-2">
|
||||
<form class="space-y-4" @submit.prevent="submitApiToken">
|
||||
<BaseInputGroup
|
||||
:label="$t('modules.api_token')"
|
||||
required
|
||||
:error="v$.api_token.$error ? String(v$.api_token.$errors[0]?.$message) : undefined"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="moduleStore.currentUser.api_token"
|
||||
:invalid="v$.api_token.$error"
|
||||
@input="v$.api_token.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BaseButton :loading="isSaving" type="submit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
Save Token
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="moduleStore.apiToken"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="clearApiToken"
|
||||
>
|
||||
Clear Token
|
||||
</BaseButton>
|
||||
|
||||
<a :href="tokenPageUrl" target="_blank" rel="noopener" class="inline-flex">
|
||||
<BaseButton variant="primary-outline" type="button">
|
||||
Manage Token
|
||||
</BaseButton>
|
||||
</a>
|
||||
<div v-if="pairingCode" class="mt-4 space-y-1 text-sm text-body">
|
||||
<p>Enter code <strong>{{ pairingCode.user_code }}</strong> at the marketplace verification page.</p>
|
||||
<a v-if="pairingCode.verification_uri_complete || pairingCode.verification_uri" class="text-primary-600 underline" :href="pairingCode.verification_uri_complete || pairingCode.verification_uri || undefined" target="_blank" rel="noopener">Open verification page</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BaseButton v-if="!moduleStore.marketplacePairing?.paired" :loading="isPairing" @click="startPairing">
|
||||
Pair marketplace
|
||||
</BaseButton>
|
||||
<BaseButton v-if="pairingCode" variant="primary-outline" :loading="isPolling" @click="pollPairing">
|
||||
I have approved this device
|
||||
</BaseButton>
|
||||
<BaseButton v-if="moduleStore.marketplacePairing?.paired" variant="primary-outline" @click="disconnect">
|
||||
Disconnect
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -70,28 +38,16 @@
|
||||
<BaseTab :title="$t('general.all')" filter="" />
|
||||
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
|
||||
</BaseTabGroup>
|
||||
|
||||
<div
|
||||
v-if="isFetchingModule"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-if="isFetchingModule" class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-for="n in 3" :key="n" class="h-80 bg-surface-tertiary rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="filteredModules.length"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-for="(mod, idx) in filteredModules" :key="idx">
|
||||
<ModuleCard :data="mod" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-24">
|
||||
<label class="flex items-center justify-center text-muted">
|
||||
{{ $t('modules.no_modules_installed') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="filteredModules.length" class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ModuleCard v-for="mod in filteredModules" :key="mod.slug" :data="mod" />
|
||||
</div>
|
||||
<div v-else class="mt-24">
|
||||
<label class="flex items-center justify-center text-muted">
|
||||
{{ activeTab === 'INSTALLED' ? $t('modules.no_modules_installed') : 'No marketplace modules are available yet.' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
@@ -99,103 +55,26 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { useModuleStore } from '../store'
|
||||
import ModuleCard from '../components/ModuleCard.vue'
|
||||
import type { Module } from '../../../../types/domain/module'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import type { MarketplacePairingCode } from '@/scripts/api/services/module.service'
|
||||
import type { Module } from '@/scripts/types/domain/module'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref('')
|
||||
const isFetchingModule = ref(false)
|
||||
const isPairing = ref(false)
|
||||
const isPolling = ref(false)
|
||||
const pairingCode = ref<MarketplacePairingCode | null>(null)
|
||||
|
||||
const activeTab = ref<string>('')
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingModule = ref<boolean>(false)
|
||||
|
||||
const rules = computed(() => ({
|
||||
api_token: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => moduleStore.currentUser),
|
||||
)
|
||||
|
||||
const filteredModules = computed<Module[]>(() => {
|
||||
if (activeTab.value === 'INSTALLED') {
|
||||
return moduleStore.installedModules
|
||||
}
|
||||
return moduleStore.modules
|
||||
})
|
||||
|
||||
const statusLabel = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'Invalid token'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'Premium modules unlocked'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'Connected'
|
||||
}
|
||||
|
||||
return 'Public modules only'
|
||||
})
|
||||
|
||||
const statusClass = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'bg-amber-100 text-amber-800'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
}
|
||||
|
||||
return 'bg-surface-secondary text-muted'
|
||||
})
|
||||
|
||||
const baseUrl = computed<string>(() => {
|
||||
return String(globalStore.config?.base_url ?? '')
|
||||
})
|
||||
|
||||
const tokenPageUrl = computed<string>(() => {
|
||||
return `${baseUrl.value}/marketplace/token`
|
||||
})
|
||||
const filteredModules = computed<Module[]>(() => activeTab.value === 'INSTALLED'
|
||||
? moduleStore.installedModules
|
||||
: moduleStore.modules)
|
||||
|
||||
onMounted(async () => {
|
||||
const savedToken = String(globalStore.globalSettings?.api_token ?? '').trim() || null
|
||||
moduleStore.setApiToken(savedToken)
|
||||
|
||||
if (savedToken) {
|
||||
const response = await moduleStore.checkApiToken(savedToken)
|
||||
if (response.error === 'invalid_token') {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: 'Saved marketplace token is invalid. Public modules are shown until you update it.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
}
|
||||
|
||||
await fetchModulesData()
|
||||
await Promise.all([moduleStore.fetchMarketplacePairing(), fetchModulesData()])
|
||||
})
|
||||
|
||||
async function fetchModulesData(): Promise<void> {
|
||||
@@ -207,55 +86,34 @@ async function fetchModulesData(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApiToken(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
async function startPairing(): Promise<void> {
|
||||
isPairing.value = true
|
||||
try {
|
||||
const token = moduleStore.currentUser.api_token ?? ''
|
||||
const response = await moduleStore.checkApiToken(token)
|
||||
|
||||
if (!response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: response.error === 'invalid_token'
|
||||
? 'Invalid marketplace token'
|
||||
: 'Unable to validate marketplace token',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: token,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token saved',
|
||||
})
|
||||
|
||||
moduleStore.setApiToken(token)
|
||||
await fetchModulesData()
|
||||
pairingCode.value = await moduleStore.startMarketplacePairing()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
isPairing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearApiToken(): Promise<void> {
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: null,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token cleared',
|
||||
})
|
||||
async function pollPairing(): Promise<void> {
|
||||
isPolling.value = true
|
||||
try {
|
||||
const result = await moduleStore.pollMarketplacePairing()
|
||||
if (result.status === 'paired') {
|
||||
pairingCode.value = null
|
||||
notificationStore.showNotification({ type: 'success', message: 'Marketplace paired' })
|
||||
await fetchModulesData()
|
||||
} else {
|
||||
notificationStore.showNotification({ type: 'info', message: 'Waiting for marketplace approval' })
|
||||
}
|
||||
} finally {
|
||||
isPolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
moduleStore.setApiToken(null)
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
v$.value.$reset()
|
||||
async function disconnect(): Promise<void> {
|
||||
await moduleStore.disconnectMarketplace()
|
||||
notificationStore.showNotification({ type: 'success', message: 'Marketplace disconnected' })
|
||||
await fetchModulesData()
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,28 @@ export interface Module {
|
||||
slug: string
|
||||
module_name: string
|
||||
access_tier: 'public' | 'premium'
|
||||
access: 'free' | 'paid'
|
||||
entitlement?: {
|
||||
active?: boolean
|
||||
status?: string
|
||||
expires_at?: string | null
|
||||
} | null
|
||||
compatibility?: {
|
||||
invoiceshelf?: string | null
|
||||
module_api?: string | number | null
|
||||
php?: string | null
|
||||
extensions?: string[]
|
||||
compatible?: boolean
|
||||
} | null
|
||||
release_state?: 'published' | 'yanked' | string
|
||||
yanked_reason?: string | null
|
||||
channel?: 'stable' | 'insider'
|
||||
faq: ModuleFaq[] | null
|
||||
highlights: string[] | null
|
||||
installed_module_version: string | null
|
||||
installed_module_version_updated_at: string | null
|
||||
latest_module_version: string
|
||||
latest_module_version_updated_at: string
|
||||
latest_module_version: string | null
|
||||
latest_module_version_updated_at: string | null
|
||||
latest_min_invoiceshelf_version: string | null
|
||||
latest_module_checksum_sha256: string | null
|
||||
is_dev: boolean
|
||||
@@ -49,6 +65,7 @@ export interface Module {
|
||||
monthly_price: number | null
|
||||
name: string
|
||||
purchased: boolean
|
||||
purchase_url: string | null
|
||||
reviews: ModuleReview[]
|
||||
screenshots: ModuleScreenshot[] | null
|
||||
short_description: string | null
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
<!-- Module Styles -->
|
||||
@foreach(\InvoiceShelf\Modules\Registry::allStyles() as $name => $path)
|
||||
<link rel="stylesheet" href="/modules/styles/{{ $name }}">
|
||||
@php($version = \App\Support\Module\ModuleAssetVersion::forPath($path))
|
||||
<link rel="stylesheet" href="/modules/styles/{{ $name }}@if($version)?v={{ $version }}@endif">
|
||||
@endforeach
|
||||
|
||||
@vite('resources/scripts/main.ts')
|
||||
@@ -42,7 +43,8 @@
|
||||
@if (\Illuminate\Support\Str::startsWith($path, ['http://', 'https://']))
|
||||
<script type="module" src="{!! $path !!}"></script>
|
||||
@else
|
||||
<script type="module" src="/modules/scripts/{{ $name }}"></script>
|
||||
@php($version = \App\Support\Module\ModuleAssetVersion::forPath($path))
|
||||
<script type="module" src="/modules/scripts/{{ $name }}@if($version)?v={{ $version }}@endif"></script>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
|
||||
+6
-6
@@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\CompaniesController;
|
||||
use App\Http\Controllers\Admin\CountriesController;
|
||||
use App\Http\Controllers\Admin\CurrenciesController;
|
||||
use App\Http\Controllers\Admin\FontController;
|
||||
use App\Http\Controllers\Admin\Modules\MarketplacePairingController;
|
||||
use App\Http\Controllers\Admin\Modules\ModuleInstallationController;
|
||||
use App\Http\Controllers\Admin\Modules\ModulesController;
|
||||
use App\Http\Controllers\Admin\Settings\AiConfigurationController;
|
||||
@@ -521,16 +522,15 @@ Route::prefix('/v1')->group(function () {
|
||||
|
||||
Route::prefix('/modules')->group(function () {
|
||||
Route::get('/', [ModulesController::class, 'index']);
|
||||
Route::get('/check', [ModulesController::class, 'checkToken']);
|
||||
Route::get('/pairing', [MarketplacePairingController::class, 'status']);
|
||||
Route::post('/pairing/start', [MarketplacePairingController::class, 'start']);
|
||||
Route::post('/pairing/poll', [MarketplacePairingController::class, 'poll']);
|
||||
Route::delete('/pairing', [MarketplacePairingController::class, 'disconnect']);
|
||||
Route::get('/{module}', [ModulesController::class, 'show']);
|
||||
Route::post('/{module}/enable', [ModulesController::class, 'enable']);
|
||||
Route::post('/{module}/disable', [ModulesController::class, 'disable']);
|
||||
|
||||
Route::post('/download', [ModuleInstallationController::class, 'download']);
|
||||
Route::post('/upload', [ModuleInstallationController::class, 'upload']);
|
||||
Route::post('/unzip', [ModuleInstallationController::class, 'unzip']);
|
||||
Route::post('/copy', [ModuleInstallationController::class, 'copy']);
|
||||
Route::post('/complete', [ModuleInstallationController::class, 'complete']);
|
||||
Route::post('/install', [ModuleInstallationController::class, 'install']);
|
||||
|
||||
// Per-slug settings (schema-driven, per-company storage)
|
||||
Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']);
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
!public/
|
||||
!templates/
|
||||
!.gitignore
|
||||
!modules_statuses.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"HelloWorld": true
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
Sanctum::actingAs(User::find(1), ['*']);
|
||||
});
|
||||
|
||||
it('allows super admins to validate marketplace tokens without a company header in admin mode', function () {
|
||||
getJson('/api/v1/modules/check?api_token=test-marketplace-token')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'error' => 'invalid_token',
|
||||
]);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ use App\Models\Module as InstalledModule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('maps the marketplace payload shape expected by the admin modules ui', function () {
|
||||
$payload = (object) [
|
||||
$payload = [
|
||||
'id' => 7,
|
||||
'slug' => 'sales-tax-us',
|
||||
'name' => 'Sales Tax US',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$user = User::findOrFail(1);
|
||||
$this->withHeaders(['company' => $user->companies()->firstOrFail()->id]);
|
||||
Sanctum::actingAs($user, ['*']);
|
||||
});
|
||||
|
||||
test('bootstrap does not expose the retired marketplace API token', function () {
|
||||
Setting::setSetting('api_token', 'legacy-marketplace-token');
|
||||
|
||||
getJson('/api/v1/bootstrap')
|
||||
->assertOk()
|
||||
->assertJsonMissingPath('global_settings.api_token')
|
||||
->assertDontSee('legacy-marketplace-token');
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Module;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\putJson;
|
||||
|
||||
/**
|
||||
* Integration test that exercises the real Modules/HelloWorld module end-to-end
|
||||
* — no Registry mocking. Proves that when an active module's ServiceProvider
|
||||
* registers menu + settings via InvoiceShelf\Modules\Registry, the host app's
|
||||
* company-modules index and settings controllers surface it consistently.
|
||||
*
|
||||
* The HelloWorld module's provider boots automatically because nwidart sees
|
||||
* it in `storage/app/modules_statuses.json` (set to enabled when the module
|
||||
* was generated via `php artisan module:make HelloWorld`).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$user = User::find(1);
|
||||
$this->companyId = $user->companies()->first()->id;
|
||||
$this->withHeaders([
|
||||
'company' => $this->companyId,
|
||||
]);
|
||||
Sanctum::actingAs($user, ['*']);
|
||||
|
||||
// Mark the module as activated at the InvoiceShelf instance level so it
|
||||
// shows up in the company-context Active Modules index.
|
||||
Module::query()->updateOrCreate(
|
||||
['name' => 'HelloWorld'],
|
||||
['version' => '1.0.0', 'installed' => true, 'enabled' => true],
|
||||
);
|
||||
});
|
||||
|
||||
test('bootstrap merges HelloWorld into main_menu under modules group', function () {
|
||||
$response = getJson('api/v1/bootstrap')->assertOk();
|
||||
|
||||
$mainMenu = collect($response->json('main_menu'));
|
||||
$helloWorld = $mainMenu->firstWhere('name', 'module-hello-world');
|
||||
|
||||
expect($helloWorld)->not->toBeNull();
|
||||
expect($helloWorld['link'])->toBe('/admin/modules/hello-world/dashboard');
|
||||
expect($helloWorld['icon'])->toBe('HandRaisedIcon');
|
||||
expect($helloWorld['group'])->toBe('modules');
|
||||
});
|
||||
|
||||
test('HelloWorld appears in the company Active Modules index with translated display name', function () {
|
||||
$response = getJson('api/v1/company-modules')->assertOk();
|
||||
|
||||
// The DB row stores PascalCase but the controller normalizes to kebab-case
|
||||
// for the URL/registry slug.
|
||||
$row = collect($response->json('data'))->firstWhere('slug', 'hello-world');
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row['name'])->toBe('HelloWorld');
|
||||
expect($row['display_name'])->toBe('Hello World');
|
||||
expect($row['has_settings'])->toBeTrue();
|
||||
expect($row['menu']['title'])->toBe('Hello World');
|
||||
expect($row['menu']['icon'])->toBe('HandRaisedIcon');
|
||||
});
|
||||
|
||||
test('GET module settings returns the translated HelloWorld schema with defaults', function () {
|
||||
$response = getJson('api/v1/modules/hello-world/settings')->assertOk();
|
||||
|
||||
$sections = $response->json('schema.sections');
|
||||
expect($sections)->toHaveCount(2);
|
||||
expect($sections[0]['title'])->toBe('Greeting');
|
||||
|
||||
$fields = collect($sections[0]['fields'])->keyBy('key');
|
||||
expect($fields)->toHaveKeys(['greeting', 'recipient', 'show_emoji']);
|
||||
expect($fields['greeting']['type'])->toBe('text');
|
||||
expect($fields['greeting']['label'])->toBe('Greeting message');
|
||||
expect($fields['greeting']['rules'])->toContain('required');
|
||||
|
||||
// Defaults flow through when nothing has been saved yet
|
||||
$values = $response->json('values');
|
||||
expect($values['greeting'])->toBe('Hello, world!');
|
||||
expect($values['show_emoji'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('PUT module settings persists values per company', function () {
|
||||
putJson('api/v1/modules/hello-world/settings', [
|
||||
'greeting' => 'Bonjour!',
|
||||
'recipient' => 'Marie',
|
||||
'show_emoji' => false,
|
||||
'tone' => 'formal',
|
||||
'note' => 'A custom welcome.',
|
||||
])->assertOk();
|
||||
|
||||
expect(CompanySetting::getSetting('module.hello-world.greeting', $this->companyId))
|
||||
->toBe('Bonjour!');
|
||||
expect(CompanySetting::getSetting('module.hello-world.show_emoji', $this->companyId))
|
||||
->toBe('0');
|
||||
expect(CompanySetting::getSetting('module.hello-world.tone', $this->companyId))
|
||||
->toBe('formal');
|
||||
|
||||
// Re-fetch and confirm the values round-trip through the show endpoint
|
||||
$response = getJson('api/v1/modules/hello-world/settings')->assertOk();
|
||||
expect($response->json('values.greeting'))->toBe('Bonjour!');
|
||||
expect($response->json('values.tone'))->toBe('formal');
|
||||
});
|
||||
|
||||
test('PUT rejects when required fields are missing', function () {
|
||||
putJson('api/v1/modules/hello-world/settings', [
|
||||
'recipient' => 'No greeting given',
|
||||
])->assertStatus(422)
|
||||
->assertJsonValidationErrors(['greeting', 'tone']);
|
||||
});
|
||||
@@ -10,8 +10,7 @@ use Illuminate\Support\Facades\File;
|
||||
* and the starter i18n files that the boilerplate references.
|
||||
*
|
||||
* The test generates a throwaway module, inspects the generated files, then
|
||||
* cleans up (including nwidart's status entry) so the rest of the suite is
|
||||
* unaffected.
|
||||
* cleans it up so the rest of the suite is unaffected.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->scaffoldModule = 'ScaffoldProbe';
|
||||
@@ -28,15 +27,6 @@ afterEach(function () {
|
||||
File::deleteDirectory($this->scaffoldPath);
|
||||
}
|
||||
|
||||
// nwidart writes module activation state to storage/app/modules_statuses.json
|
||||
// when module:make auto-enables the new module. Remove our scaffold entry
|
||||
// so the file doesn't accumulate stale test data across runs.
|
||||
$statusesFile = storage_path('app/modules_statuses.json');
|
||||
if (File::exists($statusesFile)) {
|
||||
$statuses = json_decode(File::get($statusesFile), true) ?? [];
|
||||
unset($statuses[$this->scaffoldModule]);
|
||||
File::put($statusesFile, json_encode($statuses, JSON_PRETTY_PRINT));
|
||||
}
|
||||
});
|
||||
|
||||
test('module:make generates a ServiceProvider that uses InvoiceShelf\\Modules\\Registry', function () {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Marketplace\CanonicalJson;
|
||||
|
||||
it('canonicalizes signed manifests recursively without changing list order', function () {
|
||||
$manifest = [
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['php' => '8.4', 'extensions' => ['zip', 'sodium']],
|
||||
'artifact' => ['bytes' => 10.0, 'sha256' => 'abc'],
|
||||
];
|
||||
|
||||
expect(CanonicalJson::encode($manifest))->toBe(
|
||||
'{"artifact":{"bytes":10.0,"sha256":"abc"},"compatibility":{"extensions":["zip","sodium"],"php":"8.4"},"version":"1.0.0"}',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Marketplace\MarketplaceClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('downloads HTTP artifacts from the configured local marketplace origin', function () {
|
||||
config()->set('invoiceshelf.base_url', 'http://marketplace.test:8080');
|
||||
$destination = tempnam(sys_get_temp_dir(), 'marketplace-artifact-');
|
||||
|
||||
Http::fake([
|
||||
'http://marketplace.test:8080/artifacts/secure-probe.zip' => Http::response('archive'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = app(MarketplaceClient::class)->artifact(
|
||||
'http://marketplace.test:8080/artifacts/secure-probe.zip',
|
||||
$destination,
|
||||
);
|
||||
|
||||
expect($response->successful())->toBeTrue();
|
||||
Http::assertSent(fn ($request): bool => $request->url() === 'http://marketplace.test:8080/artifacts/secure-probe.zip');
|
||||
} finally {
|
||||
unlink($destination);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects HTTP artifacts from unrelated marketplace origins', function (string $url) {
|
||||
config()->set('invoiceshelf.base_url', 'http://marketplace.test:8080');
|
||||
$destination = tempnam(sys_get_temp_dir(), 'marketplace-artifact-');
|
||||
|
||||
try {
|
||||
expect(fn () => app(MarketplaceClient::class)->artifact($url, $destination))
|
||||
->toThrow(RuntimeException::class, 'unsafe artifact URL');
|
||||
Http::assertNothingSent();
|
||||
} finally {
|
||||
unlink($destination);
|
||||
}
|
||||
})->with([
|
||||
'different host' => 'http://artifacts.test:8080/secure-probe.zip',
|
||||
'different port' => 'http://marketplace.test/secure-probe.zip',
|
||||
]);
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Module;
|
||||
use App\Services\Marketplace\CanonicalJson;
|
||||
use App\Services\Marketplace\MarketplaceInstaller;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(base_path('Modules/SecureProbe'));
|
||||
File::deleteDirectory(base_path('Modules/.staging'));
|
||||
File::deleteDirectory(base_path('Modules/.backups'));
|
||||
});
|
||||
|
||||
it('installs an exact signed marketplace archive', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease();
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeTrue()
|
||||
->and(base_path('Modules/SecureProbe/module.json'))->toBeFile()
|
||||
->and(Module::query()->where('name', 'SecureProbe')->value('version'))->toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('rejects a release signed by an unknown key before downloading an artifact', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease();
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
config()->set('invoiceshelf.marketplace.public_keys', ['other-key' => base64_encode(sodium_crypto_sign_publickey($keypair))]);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()->and($result['error'])->toContain('unknown signing key');
|
||||
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://artifacts.test/'));
|
||||
});
|
||||
|
||||
it('rejects envelope integrity metadata that differs from the signed manifest', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease();
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair, ['bytes' => strlen($archive) + 1]);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()->and($result['error'])->toContain('integrity fields');
|
||||
});
|
||||
|
||||
it('rejects missing required PHP extensions before downloading an artifact', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease(['compatibility' => [
|
||||
'invoiceshelf' => '^3.0.0', 'module_api' => '^1.0.0', 'php' => '^8.4.0', 'extensions' => ['ext-no-such-extension'],
|
||||
]]);
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()->and($result['error'])->toContain('extension');
|
||||
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://artifacts.test/'));
|
||||
});
|
||||
|
||||
it('rejects path traversal archives even with valid signed integrity metadata', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease([], ['SecureProbe/../escape.php' => 'unsafe']);
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()->and($result['error'])->toContain('unsafe path')
|
||||
->and(base_path('Modules/SecureProbe'))->not->toBeDirectory();
|
||||
});
|
||||
|
||||
it('restores installation state when a module migration fails', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease([], [
|
||||
'SecureProbe/database/migrations/2026_08_05_000000_fail_probe.php' => <<<'PHP'
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
throw new RuntimeException('probe migration failed');
|
||||
}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
PHP,
|
||||
]);
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()
|
||||
->and(base_path('Modules/SecureProbe'))->not->toBeDirectory()
|
||||
->and(Module::query()->where('name', 'SecureProbe')->value('state'))->toBe('failed');
|
||||
});
|
||||
|
||||
it('rejects package composer metadata that does not match the official module contract', function () {
|
||||
[$archive, $manifest, $keypair] = marketplaceRelease([], [], ['license' => 'MIT']);
|
||||
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
||||
|
||||
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
||||
|
||||
expect($result['success'])->toBeFalse()
|
||||
->and($result['error'])->toContain('composer manifest');
|
||||
});
|
||||
|
||||
/** @return array{string, array<string, mixed>, string} */
|
||||
function marketplaceRelease(array $changes = [], array $extraEntries = [], array $composerChanges = []): array
|
||||
{
|
||||
config()->set('app.version', '3.0.0');
|
||||
config()->set('invoiceshelf.marketplace.module_api_version', '1.0.0');
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'marketplace-test-');
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$module = [
|
||||
'name' => 'SecureProbe', 'alias' => 'secure_probe', 'description' => 'Secure test module', 'keywords' => [], 'priority' => 0,
|
||||
'providers' => ['Modules\\SecureProbe\\Providers\\SecureProbeServiceProvider'], 'aliases' => [], 'files' => [], 'requires' => [],
|
||||
'schema_version' => 1, 'slug' => 'secure-probe', 'version' => '1.0.0', 'license' => 'AGPL-3.0-only',
|
||||
'compatibility' => ['invoiceshelf' => '^3.0.0', 'module_api' => '^1.0.0', 'php' => '^8.4.0', 'extensions' => []],
|
||||
'module_dependencies' => [], 'migration_policy' => 'forward-only', 'dependency_policy' => 'host-provided-only', 'assets' => ['dist/app.js'],
|
||||
];
|
||||
$zip->addFromString('SecureProbe/module.json', json_encode($module, JSON_THROW_ON_ERROR));
|
||||
$zip->addFromString('SecureProbe/composer.json', json_encode([
|
||||
'name' => 'invoiceshelf/module-secure-probe',
|
||||
'license' => 'AGPL-3.0-only',
|
||||
'require' => ['php' => '^8.4', 'invoiceshelf/modules' => '^3.0'],
|
||||
...$composerChanges,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$zip->addFromString('SecureProbe/dist/app.js', 'export {}');
|
||||
$zip->addFromString('SecureProbe/app/Providers/SecureProbeServiceProvider.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace Modules\SecureProbe\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class SecureProbeServiceProvider extends ServiceProvider {}
|
||||
PHP);
|
||||
foreach ($extraEntries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
$archive = (string) file_get_contents($zipPath);
|
||||
unlink($zipPath);
|
||||
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$manifest = [
|
||||
'schema_version' => 1, 'slug' => 'secure-probe', 'module_name' => 'SecureProbe', 'version' => '1.0.0',
|
||||
'channel' => 'stable', 'publication' => 'published', 'compatibility' => $module['compatibility'],
|
||||
'artifact' => ['sha256' => hash('sha256', $archive), 'bytes' => strlen($archive)], 'key_id' => 'test-key',
|
||||
'source_commit' => str_repeat('a', 40), 'released_at' => '2026-08-05T12:00:00Z',
|
||||
];
|
||||
foreach ($changes as $key => $value) {
|
||||
$manifest[$key] = $value;
|
||||
}
|
||||
|
||||
return [$archive, $manifest, $keypair];
|
||||
}
|
||||
|
||||
function fakeMarketplaceRelease(string $archive, array $manifest, string $keypair, array $artifactChanges = []): void
|
||||
{
|
||||
config()->set('invoiceshelf.base_url', 'https://marketplace.test');
|
||||
config()->set('invoiceshelf.marketplace.public_keys', ['test-key' => base64_encode(sodium_crypto_sign_publickey($keypair))]);
|
||||
$artifact = [...$manifest['artifact'], 'download_url' => 'https://artifacts.test/secure-probe.zip', 'expires_at' => now()->addMinute()->toIso8601String(), ...$artifactChanges];
|
||||
$envelope = [
|
||||
'success' => true, 'manifest' => $manifest,
|
||||
'signature' => base64_encode(sodium_crypto_sign_detached(CanonicalJson::encode($manifest), sodium_crypto_sign_secretkey($keypair))),
|
||||
'key_id' => 'test-key', 'release_state' => 'published', 'yanked_reason' => null, 'artifact' => $artifact,
|
||||
];
|
||||
Http::fake([
|
||||
'https://marketplace.test/api/marketplace/v1/modules/secure-probe/releases/1.0.0/download' => Http::response($envelope),
|
||||
'https://artifacts.test/*' => Http::response($archive),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\MarketplaceCredential;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\deleteJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
Sanctum::actingAs(User::findOrFail(1), ['*']);
|
||||
config()->set('invoiceshelf.base_url', 'https://marketplace.test');
|
||||
});
|
||||
|
||||
it('starts pairing with installation compatibility metadata', function () {
|
||||
Http::fake([
|
||||
'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
|
||||
'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
|
||||
'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
|
||||
], 201),
|
||||
]);
|
||||
|
||||
postJson('/api/v1/modules/pairing/start')
|
||||
->assertCreated()
|
||||
->assertJsonPath('user_code', 'ABCD1234');
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$data = $request->data();
|
||||
|
||||
return $request->url() === 'https://marketplace.test/api/marketplace/v1/device/code'
|
||||
&& filled($data['installation_name'] ?? null)
|
||||
&& isset($data['module_api_version'], $data['php_version'], $data['extensions'])
|
||||
&& collect($data['extensions'])->every(
|
||||
fn ($extension): bool => is_string($extension)
|
||||
&& preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $extension) === 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('stores only the encrypted opaque installation token after pairing', function () {
|
||||
Http::fake([
|
||||
'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
|
||||
'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
|
||||
'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
|
||||
], 201),
|
||||
'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([
|
||||
'success' => true, 'installation_token' => 'opaque-installation-token', 'installation' => ['id' => 17, 'name' => 'Local'],
|
||||
]),
|
||||
]);
|
||||
|
||||
postJson('/api/v1/modules/pairing/start')->assertCreated();
|
||||
postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'paired');
|
||||
|
||||
$credential = MarketplaceCredential::query()->sole();
|
||||
expect($credential->credential)->not->toContain('opaque-installation-token')
|
||||
->and(Crypt::decryptString($credential->credential))->toBe('opaque-installation-token')
|
||||
->and($credential->device_id)->toBe('17');
|
||||
});
|
||||
|
||||
it('reports pending device approval without storing a credential', function () {
|
||||
Http::fake([
|
||||
'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
|
||||
'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
|
||||
'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
|
||||
], 201),
|
||||
'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([
|
||||
'success' => false, 'error' => 'authorization_pending', 'interval' => 5,
|
||||
], 428),
|
||||
]);
|
||||
|
||||
postJson('/api/v1/modules/pairing/start')->assertCreated();
|
||||
postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'pending');
|
||||
|
||||
expect(MarketplaceCredential::query()->doesntExist())->toBeTrue();
|
||||
});
|
||||
|
||||
it('revokes the remote installation when disconnecting locally', function () {
|
||||
MarketplaceCredential::query()->create([
|
||||
'credential' => Crypt::encryptString('opaque-installation-token'),
|
||||
'paired_at' => now(),
|
||||
]);
|
||||
Http::fake([
|
||||
'https://marketplace.test/api/marketplace/v1/device' => Http::response(['success' => true]),
|
||||
]);
|
||||
|
||||
deleteJson('/api/v1/modules/pairing')->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
expect(MarketplaceCredential::query()->exists())->toBeFalse();
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://marketplace.test/api/marketplace/v1/device'
|
||||
&& $request->hasHeader('Authorization', 'Bearer opaque-installation-token'));
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Marketplace\ModuleRuntimeAutoloader;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\AutoloadProbe\Providers\AutoloadProbeServiceProvider;
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(base_path('Modules/AutoloadProbe'));
|
||||
});
|
||||
|
||||
it('autoloads an installed module before Laravel package providers are registered', function () {
|
||||
$modulePath = base_path('Modules/AutoloadProbe');
|
||||
File::ensureDirectoryExists($modulePath.'/app/Providers');
|
||||
File::put($modulePath.'/module.json', json_encode(['name' => 'AutoloadProbe'], JSON_THROW_ON_ERROR));
|
||||
File::put($modulePath.'/app/Providers/AutoloadProbeServiceProvider.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace Modules\AutoloadProbe\Providers;
|
||||
|
||||
class AutoloadProbeServiceProvider {}
|
||||
PHP);
|
||||
|
||||
ModuleRuntimeAutoloader::registerInstalledModules(base_path('Modules'));
|
||||
|
||||
expect(class_exists(AutoloadProbeServiceProvider::class))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Module\ModuleAssetVersion;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
beforeEach(function () {
|
||||
Registry::flush();
|
||||
$this->assetDirectory = storage_path('app/module-asset-cache-test');
|
||||
File::ensureDirectoryExists($this->assetDirectory);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Registry::flush();
|
||||
File::deleteDirectory($this->assetDirectory);
|
||||
});
|
||||
|
||||
test('script responses use immutable caching only for the current content version', function () {
|
||||
$path = $this->assetDirectory.'/cache-probe.js';
|
||||
File::put($path, 'export const version = "1.0.0";');
|
||||
Registry::registerScript('cache-probe', $path);
|
||||
$firstVersion = ModuleAssetVersion::forPath($path);
|
||||
|
||||
$unversionedResponse = get('/modules/scripts/cache-probe')
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'application/javascript')
|
||||
->assertSee('version = "1.0.0";', false);
|
||||
|
||||
expect($unversionedResponse->headers->get('Cache-Control'))->toContain('no-store');
|
||||
|
||||
File::put($path, 'export const version = "1.0.1";');
|
||||
$currentVersion = ModuleAssetVersion::forPath($path);
|
||||
|
||||
expect($currentVersion)->not->toBe($firstVersion);
|
||||
|
||||
$staleResponse = get('/modules/scripts/cache-probe?v='.$firstVersion)
|
||||
->assertOk()
|
||||
->assertSee('version = "1.0.1";', false);
|
||||
|
||||
expect($staleResponse->headers->get('Cache-Control'))->toContain('no-store');
|
||||
|
||||
$versionedResponse = get('/modules/scripts/cache-probe?v='.$currentVersion)
|
||||
->assertOk()
|
||||
->assertSee('version = "1.0.1";', false);
|
||||
|
||||
expect($versionedResponse->headers->get('Cache-Control'))->toContain('public')
|
||||
->toContain('max-age=31536000')
|
||||
->toContain('immutable');
|
||||
});
|
||||
|
||||
test('style responses use immutable caching only for the current content version', function () {
|
||||
$path = $this->assetDirectory.'/cache-probe.css';
|
||||
File::put($path, '.cache-probe { color: red; }');
|
||||
Registry::registerStyle('cache-probe', $path);
|
||||
$version = ModuleAssetVersion::forPath($path);
|
||||
|
||||
$outdatedResponse = get('/modules/styles/cache-probe?v=outdated')
|
||||
->assertOk()
|
||||
->assertSee('.cache-probe { color: red; }', false);
|
||||
|
||||
expect($outdatedResponse->headers->get('Content-Type'))->toStartWith('text/css')
|
||||
->and($outdatedResponse->headers->get('Cache-Control'))->toContain('no-store');
|
||||
|
||||
$versionedResponse = get('/modules/styles/cache-probe?v='.$version)
|
||||
->assertOk()
|
||||
->assertSee('.cache-probe { color: red; }', false);
|
||||
|
||||
expect($versionedResponse->headers->get('Cache-Control'))->toContain('public')
|
||||
->toContain('max-age=31536000')
|
||||
->toContain('immutable');
|
||||
});
|
||||
|
||||
test('the application shell content-versions local module asset URLs', function () {
|
||||
$scriptPath = $this->assetDirectory.'/layout-probe.js';
|
||||
$stylePath = $this->assetDirectory.'/layout-probe.css';
|
||||
File::put($scriptPath, 'export const version = "1.0.1";');
|
||||
File::put($stylePath, '.layout-probe { color: blue; }');
|
||||
Registry::registerScript('layout-probe', $scriptPath);
|
||||
Registry::registerStyle('layout-probe', $stylePath);
|
||||
|
||||
$html = view('app')->render();
|
||||
|
||||
expect($html)->toContain('/modules/scripts/layout-probe?v='.ModuleAssetVersion::forPath($scriptPath))
|
||||
->toContain('/modules/styles/layout-probe?v='.ModuleAssetVersion::forPath($stylePath));
|
||||
});
|
||||
+3
-4
@@ -6,8 +6,7 @@ use Tests\TestCase;
|
||||
uses(TestCase::class, RefreshDatabase::class)->in('Feature');
|
||||
uses(TestCase::class, RefreshDatabase::class)->in('Unit');
|
||||
|
||||
// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe) and
|
||||
// toggle the shared modules_statuses.json — global, filesystem-level state that paratest
|
||||
// does NOT isolate per worker (it only isolates the database). Tag them so CI can run
|
||||
// this group serially, after the parallel pass, to avoid cross-worker collisions.
|
||||
// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe).
|
||||
// Paratest isolates the database but not that shared filesystem path, so run this
|
||||
// group serially after the parallel pass to avoid cross-worker collisions.
|
||||
uses()->group('modules')->in('Feature/Company/Modules');
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
const OFFICIAL_MARKETPLACE_KEY_ID = 'official-modules-2026-01';
|
||||
const OFFICIAL_MARKETPLACE_PUBLIC_KEY = 'sIDGuOAaMVzPv9I/GPbWp9ci5aUI5HcM5rZ0tKxW6dc=';
|
||||
|
||||
test('marketplace configuration includes the official signing key by default', function () {
|
||||
expect(marketplacePublicKeysConfigFor(null))
|
||||
->toBe([OFFICIAL_MARKETPLACE_KEY_ID => OFFICIAL_MARKETPLACE_PUBLIC_KEY]);
|
||||
});
|
||||
|
||||
test('marketplace public-key configuration adds and rotates trusted keys', function () {
|
||||
$keys = marketplacePublicKeysConfigFor(json_encode([
|
||||
'rotated-modules-2027-01' => 'additional-public-key',
|
||||
OFFICIAL_MARKETPLACE_KEY_ID => 'replacement-public-key',
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
expect($keys)->toBe([
|
||||
OFFICIAL_MARKETPLACE_KEY_ID => 'replacement-public-key',
|
||||
'rotated-modules-2027-01' => 'additional-public-key',
|
||||
]);
|
||||
});
|
||||
|
||||
function marketplacePublicKeysConfigFor(?string $override): array
|
||||
{
|
||||
$previous = getenv('MARKETPLACE_PUBLIC_KEYS');
|
||||
|
||||
if ($override === null) {
|
||||
putenv('MARKETPLACE_PUBLIC_KEYS');
|
||||
unset($_ENV['MARKETPLACE_PUBLIC_KEYS'], $_SERVER['MARKETPLACE_PUBLIC_KEYS']);
|
||||
} else {
|
||||
putenv("MARKETPLACE_PUBLIC_KEYS={$override}");
|
||||
$_ENV['MARKETPLACE_PUBLIC_KEYS'] = $override;
|
||||
$_SERVER['MARKETPLACE_PUBLIC_KEYS'] = $override;
|
||||
}
|
||||
|
||||
$configuration = require config_path('invoiceshelf.php');
|
||||
|
||||
if ($previous === false) {
|
||||
putenv('MARKETPLACE_PUBLIC_KEYS');
|
||||
unset($_ENV['MARKETPLACE_PUBLIC_KEYS'], $_SERVER['MARKETPLACE_PUBLIC_KEYS']);
|
||||
} else {
|
||||
putenv("MARKETPLACE_PUBLIC_KEYS={$previous}");
|
||||
$_ENV['MARKETPLACE_PUBLIC_KEYS'] = $previous;
|
||||
$_SERVER['MARKETPLACE_PUBLIC_KEYS'] = $previous;
|
||||
}
|
||||
|
||||
return $configuration['marketplace']['public_keys'];
|
||||
}
|
||||
Reference in New Issue
Block a user