From 8579e4f85fa94e6e7ec9caa34f9f427d01190c9b Mon Sep 17 00:00:00 2001
From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com>
Date: Wed, 5 Aug 2026 03:49:42 +0200
Subject: [PATCH] feat: add secure module marketplace runtime (#745)
---
.env.example | 7 +
.../Http/Controllers/HelloWorldController.php | 56 --
.../app/Providers/EventServiceProvider.php | 27 -
.../Providers/HelloWorldServiceProvider.php | 122 ----
.../app/Providers/RouteServiceProvider.php | 50 --
Modules/HelloWorld/composer.json | 33 -
Modules/HelloWorld/config/config.php | 5 -
.../seeders/HelloWorldDatabaseSeeder.php | 16 -
Modules/HelloWorld/lang/en/menu.php | 5 -
Modules/HelloWorld/lang/en/settings.php | 12 -
Modules/HelloWorld/module.json | 11 -
Modules/HelloWorld/package.json | 12 -
Modules/HelloWorld/resources/assets/js/app.js | 0
.../HelloWorld/resources/assets/sass/app.scss | 0
Modules/HelloWorld/resources/dist/init.js | 83 ---
Modules/HelloWorld/resources/js/init.ts | 23 -
.../resources/js/pages/DashboardPage.vue | 49 --
Modules/HelloWorld/resources/js/vue-shim.js | 37 -
.../views/components/layouts/master.blade.php | 30 -
.../resources/views/index.blade.php | 5 -
Modules/HelloWorld/routes/api.php | 8 -
Modules/HelloWorld/routes/web.php | 8 -
Modules/HelloWorld/vite.config.js | 69 --
.../Modules/MarketplacePairingController.php | 97 +++
.../Modules/ModuleInstallationController.php | 61 +-
.../Admin/Modules/ModulesController.php | 32 +-
.../Company/General/BootstrapController.php | 1 -
.../Modules/CompanyModulesController.php | 8 +-
.../Modules/ModuleSettingsController.php | 6 +-
.../Controllers/Modules/ScriptController.php | 18 +-
.../Controllers/Modules/StyleController.php | 18 +-
.../InstallMarketplaceModuleRequest.php | 30 +
app/Http/Resources/ModuleResource.php | 81 ++-
app/Models/MarketplaceCredential.php | 20 +
app/Models/MarketplaceOperation.php | 19 +
app/Models/Module.php | 9 +
app/Policies/ModulesPolicy.php | 10 +-
app/Providers/AppServiceProvider.php | 2 +
app/Services/Marketplace/CanonicalJson.php | 36 +
.../Marketplace/DatabaseActivator.php | 114 ++++
.../Marketplace/MarketplaceClient.php | 121 ++++
.../Marketplace/MarketplaceInstaller.php | 638 ++++++++++++++++++
.../Marketplace/ModuleRuntimeAutoloader.php | 64 ++
app/Support/Module/ModuleAssetVersion.php | 20 +
app/Support/Module/ModuleInstaller.php | 232 -------
app/Traits/SiteApi.php | 2 +-
bootstrap/app.php | 6 +
composer.json | 2 +-
config/invoiceshelf.php | 37 +-
config/modules.php | 14 +-
...2902_create_marketplace_runtime_tables.php | 54 ++
docker/production/docker-compose.mysql.yml | 2 +
docker/production/docker-compose.sqlite.yml | 2 +
public/openapi.json | 181 +----
resources/scripts/api/endpoints.ts | 8 +-
resources/scripts/api/index.ts | 1 -
resources/scripts/api/services/index.ts | 2 +-
.../scripts/api/services/module.service.ts | 68 +-
.../scripts/features/admin/modules/store.ts | 185 ++---
.../admin/modules/views/ModuleDetailView.vue | 8 +-
.../admin/modules/views/ModuleIndexView.vue | 258 ++-----
resources/scripts/types/domain/module.ts | 21 +-
resources/views/app.blade.php | 6 +-
routes/api.php | 12 +-
storage/app/.gitignore | 1 -
storage/app/modules_statuses.json | 3 -
.../Admin/Modules/ModuleAuthorizationTest.php | 22 -
.../Admin/Modules/ModuleResourceTest.php | 2 +-
.../Feature/Company/BootstrapSecurityTest.php | 26 +
.../Modules/HelloWorldIntegrationTest.php | 113 ----
.../Company/Modules/ModuleMakeStubTest.php | 12 +-
.../Feature/Marketplace/CanonicalJsonTest.php | 15 +
.../Marketplace/MarketplaceClientTest.php | 41 ++
.../Marketplace/MarketplaceInstallerTest.php | 172 +++++
.../Marketplace/MarketplacePairingTest.php | 97 +++
.../ModuleRuntimeAutoloaderTest.php | 26 +
.../Feature/Modules/ModuleAssetCacheTest.php | 87 +++
tests/Pest.php | 7 +-
tests/Unit/MarketplaceConfigurationTest.php | 48 ++
79 files changed, 2084 insertions(+), 1762 deletions(-)
delete mode 100644 Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php
delete mode 100644 Modules/HelloWorld/app/Providers/EventServiceProvider.php
delete mode 100644 Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php
delete mode 100644 Modules/HelloWorld/app/Providers/RouteServiceProvider.php
delete mode 100644 Modules/HelloWorld/composer.json
delete mode 100644 Modules/HelloWorld/config/config.php
delete mode 100644 Modules/HelloWorld/database/seeders/HelloWorldDatabaseSeeder.php
delete mode 100644 Modules/HelloWorld/lang/en/menu.php
delete mode 100644 Modules/HelloWorld/lang/en/settings.php
delete mode 100644 Modules/HelloWorld/module.json
delete mode 100644 Modules/HelloWorld/package.json
delete mode 100644 Modules/HelloWorld/resources/assets/js/app.js
delete mode 100644 Modules/HelloWorld/resources/assets/sass/app.scss
delete mode 100644 Modules/HelloWorld/resources/dist/init.js
delete mode 100644 Modules/HelloWorld/resources/js/init.ts
delete mode 100644 Modules/HelloWorld/resources/js/pages/DashboardPage.vue
delete mode 100644 Modules/HelloWorld/resources/js/vue-shim.js
delete mode 100644 Modules/HelloWorld/resources/views/components/layouts/master.blade.php
delete mode 100644 Modules/HelloWorld/resources/views/index.blade.php
delete mode 100644 Modules/HelloWorld/routes/api.php
delete mode 100644 Modules/HelloWorld/routes/web.php
delete mode 100644 Modules/HelloWorld/vite.config.js
create mode 100644 app/Http/Controllers/Admin/Modules/MarketplacePairingController.php
create mode 100644 app/Http/Requests/InstallMarketplaceModuleRequest.php
create mode 100644 app/Models/MarketplaceCredential.php
create mode 100644 app/Models/MarketplaceOperation.php
create mode 100644 app/Services/Marketplace/CanonicalJson.php
create mode 100644 app/Services/Marketplace/DatabaseActivator.php
create mode 100644 app/Services/Marketplace/MarketplaceClient.php
create mode 100644 app/Services/Marketplace/MarketplaceInstaller.php
create mode 100644 app/Services/Marketplace/ModuleRuntimeAutoloader.php
create mode 100644 app/Support/Module/ModuleAssetVersion.php
create mode 100644 database/migrations/2026_08_04_222902_create_marketplace_runtime_tables.php
delete mode 100644 storage/app/modules_statuses.json
delete mode 100644 tests/Feature/Admin/Modules/ModuleAuthorizationTest.php
create mode 100644 tests/Feature/Company/BootstrapSecurityTest.php
delete mode 100644 tests/Feature/Company/Modules/HelloWorldIntegrationTest.php
create mode 100644 tests/Feature/Marketplace/CanonicalJsonTest.php
create mode 100644 tests/Feature/Marketplace/MarketplaceClientTest.php
create mode 100644 tests/Feature/Marketplace/MarketplaceInstallerTest.php
create mode 100644 tests/Feature/Marketplace/MarketplacePairingTest.php
create mode 100644 tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php
create mode 100644 tests/Feature/Modules/ModuleAssetCacheTest.php
create mode 100644 tests/Unit/MarketplaceConfigurationTest.php
diff --git a/.env.example b/.env.example
index 6c943abc..5c25463c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php b/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php
deleted file mode 100644
index f83a554e..00000000
--- a/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php
+++ /dev/null
@@ -1,56 +0,0 @@
->
- */
- 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 {}
-}
diff --git a/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php b/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php
deleted file mode 100644
index d318ef84..00000000
--- a/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php
+++ /dev/null
@@ -1,122 +0,0 @@
-name);
-
- // ----------------------------------------------------------------
- // Module script (Vue page registration)
- // ----------------------------------------------------------------
- // Registers a compiled JS file that the host app injects as a
- //
diff --git a/Modules/HelloWorld/resources/js/vue-shim.js b/Modules/HelloWorld/resources/js/vue-shim.js
deleted file mode 100644
index e5fbabaa..00000000
--- a/Modules/HelloWorld/resources/js/vue-shim.js
+++ /dev/null
@@ -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
diff --git a/Modules/HelloWorld/resources/views/components/layouts/master.blade.php b/Modules/HelloWorld/resources/views/components/layouts/master.blade.php
deleted file mode 100644
index 8f45477f..00000000
--- a/Modules/HelloWorld/resources/views/components/layouts/master.blade.php
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
- HelloWorld Module - {{ config('app.name', 'Laravel') }}
-
-
-
-
-
-
-
-
-
- {{-- Vite CSS --}}
- {{-- {{ module_vite('build-helloworld', 'resources/assets/sass/app.scss') }} --}}
-
-
-
- {{ $slot }}
-
- {{-- Vite JS --}}
- {{-- {{ module_vite('build-helloworld', 'resources/assets/js/app.js') }} --}}
-
-
diff --git a/Modules/HelloWorld/resources/views/index.blade.php b/Modules/HelloWorld/resources/views/index.blade.php
deleted file mode 100644
index 6c350d07..00000000
--- a/Modules/HelloWorld/resources/views/index.blade.php
+++ /dev/null
@@ -1,5 +0,0 @@
-
- Hello World
-
- Module: {!! config('helloworld.name') !!}
-
diff --git a/Modules/HelloWorld/routes/api.php b/Modules/HelloWorld/routes/api.php
deleted file mode 100644
index ad8f2410..00000000
--- a/Modules/HelloWorld/routes/api.php
+++ /dev/null
@@ -1,8 +0,0 @@
-prefix('v1')->group(function () {
- Route::apiResource('helloworlds', HelloWorldController::class)->names('helloworld');
-});
diff --git a/Modules/HelloWorld/routes/web.php b/Modules/HelloWorld/routes/web.php
deleted file mode 100644
index 208babb4..00000000
--- a/Modules/HelloWorld/routes/web.php
+++ /dev/null
@@ -1,8 +0,0 @@
-group(function () {
- Route::resource('helloworlds', HelloWorldController::class)->names('helloworld');
-});
diff --git a/Modules/HelloWorld/vite.config.js b/Modules/HelloWorld/vite.config.js
deleted file mode 100644
index f1d593f0..00000000
--- a/Modules/HelloWorld/vite.config.js
+++ /dev/null
@@ -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
@else
-
+ @php($version = \App\Support\Module\ModuleAssetVersion::forPath($path))
+
@endif
@endforeach
diff --git a/routes/api.php b/routes/api.php
index d6111e0b..f6ec51ed 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -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']);
diff --git a/storage/app/.gitignore b/storage/app/.gitignore
index 8b58e1f2..711d1cbd 100644
--- a/storage/app/.gitignore
+++ b/storage/app/.gitignore
@@ -2,4 +2,3 @@
!public/
!templates/
!.gitignore
-!modules_statuses.json
diff --git a/storage/app/modules_statuses.json b/storage/app/modules_statuses.json
deleted file mode 100644
index 3c96ecbe..00000000
--- a/storage/app/modules_statuses.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "HelloWorld": true
-}
\ No newline at end of file
diff --git a/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php b/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php
deleted file mode 100644
index afc3c14a..00000000
--- a/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- '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',
- ]);
-});
diff --git a/tests/Feature/Admin/Modules/ModuleResourceTest.php b/tests/Feature/Admin/Modules/ModuleResourceTest.php
index 815bc967..6d641396 100644
--- a/tests/Feature/Admin/Modules/ModuleResourceTest.php
+++ b/tests/Feature/Admin/Modules/ModuleResourceTest.php
@@ -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',
diff --git a/tests/Feature/Company/BootstrapSecurityTest.php b/tests/Feature/Company/BootstrapSecurityTest.php
new file mode 100644
index 00000000..9ed247a2
--- /dev/null
+++ b/tests/Feature/Company/BootstrapSecurityTest.php
@@ -0,0 +1,26 @@
+ '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');
+});
diff --git a/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php b/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php
deleted file mode 100644
index a1958a6e..00000000
--- a/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php
+++ /dev/null
@@ -1,113 +0,0 @@
- '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']);
-});
diff --git a/tests/Feature/Company/Modules/ModuleMakeStubTest.php b/tests/Feature/Company/Modules/ModuleMakeStubTest.php
index bfe17dcb..e30f8003 100644
--- a/tests/Feature/Company/Modules/ModuleMakeStubTest.php
+++ b/tests/Feature/Company/Modules/ModuleMakeStubTest.php
@@ -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 () {
diff --git a/tests/Feature/Marketplace/CanonicalJsonTest.php b/tests/Feature/Marketplace/CanonicalJsonTest.php
new file mode 100644
index 00000000..ea829b5d
--- /dev/null
+++ b/tests/Feature/Marketplace/CanonicalJsonTest.php
@@ -0,0 +1,15 @@
+ '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"}',
+ );
+});
diff --git a/tests/Feature/Marketplace/MarketplaceClientTest.php b/tests/Feature/Marketplace/MarketplaceClientTest.php
new file mode 100644
index 00000000..2633a313
--- /dev/null
+++ b/tests/Feature/Marketplace/MarketplaceClientTest.php
@@ -0,0 +1,41 @@
+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',
+]);
diff --git a/tests/Feature/Marketplace/MarketplaceInstallerTest.php b/tests/Feature/Marketplace/MarketplaceInstallerTest.php
new file mode 100644
index 00000000..3c22425a
--- /dev/null
+++ b/tests/Feature/Marketplace/MarketplaceInstallerTest.php
@@ -0,0 +1,172 @@
+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'
+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} */
+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'
+ $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),
+ ]);
+}
diff --git a/tests/Feature/Marketplace/MarketplacePairingTest.php b/tests/Feature/Marketplace/MarketplacePairingTest.php
new file mode 100644
index 00000000..c538c458
--- /dev/null
+++ b/tests/Feature/Marketplace/MarketplacePairingTest.php
@@ -0,0 +1,97 @@
+ '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'));
+});
diff --git a/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php b/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php
new file mode 100644
index 00000000..ca960929
--- /dev/null
+++ b/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php
@@ -0,0 +1,26 @@
+ 'AutoloadProbe'], JSON_THROW_ON_ERROR));
+ File::put($modulePath.'/app/Providers/AutoloadProbeServiceProvider.php', <<<'PHP'
+toBeTrue();
+});
diff --git a/tests/Feature/Modules/ModuleAssetCacheTest.php b/tests/Feature/Modules/ModuleAssetCacheTest.php
new file mode 100644
index 00000000..77ff1f72
--- /dev/null
+++ b/tests/Feature/Modules/ModuleAssetCacheTest.php
@@ -0,0 +1,87 @@
+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));
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index efe351cc..2deca95e 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -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');
diff --git a/tests/Unit/MarketplaceConfigurationTest.php b/tests/Unit/MarketplaceConfigurationTest.php
new file mode 100644
index 00000000..59ef72ab
--- /dev/null
+++ b/tests/Unit/MarketplaceConfigurationTest.php
@@ -0,0 +1,48 @@
+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'];
+}