feat: add secure module marketplace runtime (#745)

This commit is contained in:
Darko Gjorgjijoski
2026-08-05 03:49:42 +02:00
committed by GitHub
parent f322c2b74d
commit 8579e4f85f
79 changed files with 2084 additions and 1762 deletions
+7
View File
@@ -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'));
}
}
-33
View File
@@ -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/"
}
}
}
-5
View File
@@ -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([]);
}
}
-5
View File
@@ -1,5 +0,0 @@
<?php
return [
'title' => 'Hello World',
];
-12
View File
@@ -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)',
];
-11
View File
@@ -1,11 +0,0 @@
{
"name": "HelloWorld",
"alias": "helloworld",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\HelloWorld\\Providers\\HelloWorldServiceProvider"
],
"files": []
}
-12
View File
@@ -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"
}
}
View File
View File
-83
View File
@@ -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
-23
View File
@@ -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
View File
@@ -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>
-8
View File
@@ -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');
});
-8
View File
@@ -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');
});
-69
View File
@@ -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'],
];
}
}
+48 -33
View File
@@ -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, '<');
}
}
+20
View File
@@ -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',
];
}
}
+19
View File
@@ -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',
];
}
}
+9
View File
@@ -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',
];
}
}
+1 -9
View File
@@ -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();
}
}
+2
View File
@@ -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;
}
}
+20
View File
@@ -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);
}
}
-232
View File
@@ -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']);
}
}
+1 -1
View File
@@ -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',
+6
View File
@@ -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
View File
@@ -5,7 +5,7 @@
"framework",
"laravel"
],
"license": "MIT",
"license": "AGPL-3.0-only",
"type": "project",
"require": {
"php": "^8.4",
+34 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 @@
}
}
}
}
}
+2 -6
View File
@@ -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',
-1
View File
@@ -104,7 +104,6 @@ export type {
ActiveProviderResponse,
Module,
ModuleInstallPayload,
ModuleCheckResponse,
Backup,
BackupListResponse,
CreateBackupPayload,
+1 -1
View File
@@ -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
},
}
+44 -141
View File
@@ -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()
}
+19 -2
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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']);
-1
View File
@@ -2,4 +2,3 @@
!public/
!templates/
!.gitignore
!modules_statuses.json
-3
View File
@@ -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
View File
@@ -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'];
}