From afe7f2ebbcfdec0fe4a3374de3c724b405da010e Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 16:46:31 +0200 Subject: [PATCH 1/4] feat: add registerPage types, ability registry, and member/invoice readers Introduces module_api 1.3.0: PageContribution and registerPage on the frontend extension API (and fixes the settings-page return types), Registry::registerAbility with slug-namespaced ability ids that the host merges into its ability catalogue, and CompanyDataReader::companyMembers and existingInvoiceIds for modules that assign work to members or stamp entries against invoices. Stubs now target SDK ^3.4 and dist/init.js. --- README.md | 57 +++++++-- frontend/index.d.ts | 23 ++++ src/Contracts/Host/CompanyDataReader.php | 28 +++- src/Registry.php | 156 +++++++++++++++++++++++ stubs/composer.stub | 4 +- stubs/json.stub | 6 +- tests/AiContractsTest.php | 1 + tests/ManifestTest.php | 8 +- tests/RegistryTest.php | 116 +++++++++++++++++ 9 files changed, 380 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5a4de55..e706044 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ The MIT-licensed SDK for official [InvoiceShelf](https://invoiceshelf.com) v3 modules. It extends [`nwidart/laravel-modules`](https://github.com/nWidart/laravel-modules) with the contracts used by InvoiceShelf's module host and signed marketplace packages. -SDK **3.3** supports Module API **1.2**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT. +SDK **3.4** supports Module API **1.3**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT. ## What a module can add - Sidebar entries and schema-driven, per-company settings. - Local, compiled JavaScript and CSS registered with the host. -- Typed frontend contributions through [`frontend/index.d.ts`](frontend/index.d.ts): routes, menus, HTTP access, translations, notifications, and lifecycle events. The host supplies the Vue, router, Axios, and i18n instances—modules do not bundle another framework runtime. -- Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and the AI assistant's read-only company-data queries. Module code must not depend on InvoiceShelf Eloquent models. +- Typed frontend contributions through [`frontend/index.d.ts`](frontend/index.d.ts): full-page routes, settings pages, menus, HTTP access, translations, notifications, and lifecycle events. The host supplies the Vue, router, Axios, and i18n instances—modules do not bundle another framework runtime. +- Company abilities contributed to the host's authorization catalogue, so a module can ship its own permissions instead of reusing host ones. +- Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and read-only company-data queries. `CompanyDataReader` covers the AI assistant's built-in queries plus `companyMembers()` and `existingInvoiceIds()` for modules that assign work to members or stamp their entries against invoices. Module code must not depend on InvoiceShelf Eloquent models. - AI drivers through [`src/Ai`](src/Ai): extend `AiDriver`, return `AiChatResponse`, and throw `AiException` for safe, localizable provider failures. The host controls discovery, installation, activation, migrations, and provider registration. Official packages are not a general-purpose runtime Composer installer for arbitrary third-party code. @@ -20,8 +21,8 @@ Declare compatibility in `module.json`, then test it against the host versions y | Concern | Current contract | | --- | --- | -| SDK | `invoiceshelf/modules` `^3.3` | -| Module API | `^1.2.0` | +| SDK | `invoiceshelf/modules` `^3.4` | +| Module API | `^1.3.0` | | Host application | Your explicit InvoiceShelf 3.x range | | PHP | The range your module actually supports | @@ -50,7 +51,7 @@ New official modules use schema version 2. Their identity is permanent after the ], "compatibility": { "invoiceshelf": ">=3.0.0-alpha.2 <4.0.0", - "module_api": "^1.2.0", + "module_api": "^1.3.0", "php": "^8.4.0", "extensions": ["ext-json"] }, @@ -59,7 +60,7 @@ New official modules use schema version 2. Their identity is permanent after the "uninstall": { "data_cleanup": "Modules\\SalesTaxUs\\Lifecycle\\DataCleanup" }, - "assets": ["dist/module.js", "dist/module.css"] + "assets": ["dist/init.js", "dist/style.css"] } ``` @@ -90,7 +91,7 @@ An intentionally empty method is valid for a module with nothing beyond reversib Install the SDK in the module project, then use Laravel Modules as usual: ```bash -composer require invoiceshelf/modules:^3.3 +composer require invoiceshelf/modules:^3.4 php artisan module:make SalesTaxUs ``` @@ -106,6 +107,46 @@ Registry::registerMenu('sales-tax-us', [ ]); ``` +### Abilities + +`Registry::registerAbility()` adds a module's own permissions to the host's ability catalogue. Every +ability is stored namespaced as `{slug}:{ability}`, so a module can never collide with a host ability +or with another module: + +```php +Registry::registerAbility('sales-tax-us', [ + 'ability' => 'view-filing', + 'name' => 'View Tax Filings', + 'depends_on' => ['view-invoice', Registry::abilityId('sales-tax-us', 'view-rate')], + 'owner_only' => false, +]); +``` + +`ability` is plain kebab-case and `name` is the label shown in the role editor. `depends_on` lists +abilities implied by this one: host abilities in plain form (`view-invoice`), the module's own in +namespaced form via `Registry::abilityId()`. Module abilities are never model-scoped. + +The host grants a module's abilities to owner roles when the module is enabled and removes them when +it is uninstalled; other roles get them through the role editor. Frontend pages must gate on the same +namespaced id through `meta.ability` (see below), and re-registering an identical entry is a no-op +while a conflicting redefinition throws. + +### Full-page routes + +`extensions.registerPage()` mounts a module page at `/admin/modules/{slug}/{path}`, where `{slug}` is +the `module.json` slug. Declare `meta.ability` with the namespaced ability id so the host route guard +can check it, and add `children` for sub-routes relative to the page. The `settings` path is reserved +by the host for the schema-rendered settings page. + +Point the `Registry::registerMenu` link at `/admin/modules/{slug}` for a module with its own pages, +and at `/admin/modules/{slug}/settings` for a settings-only module. + +`registerPage`, `Registry::registerAbility`, and the `CompanyDataReader::companyMembers()` and +`existingInvoiceIds()` queries are Module API **1.3.0** additions. Declare +`"module_api": "^1.3.0"` in `module.json` before using them. + +### Validating a package + Validate both the manifest and the distributable package before every release: ```bash diff --git a/frontend/index.d.ts b/frontend/index.d.ts index 98291b5..b231901 100644 --- a/frontend/index.d.ts +++ b/frontend/index.d.ts @@ -38,6 +38,28 @@ export interface SettingsPageContribution meta?: Record } +export interface PageRouteMeta { + /** Namespaced ability id(s) checked by the host route guard, e.g. 'tasks-projects:view-project'. */ + ability?: string | string[] + /** i18n key for the page title. */ + title?: string + [key: string]: unknown +} + +export interface PageChildContribution { + id: string + /** Relative to the parent page, without a leading slash. '' is the index child. */ + path: string + component: Component + meta?: PageRouteMeta +} + +export interface PageContribution extends PageChildContribution { + /** The module.json slug. The page mounts at /admin/modules/{module}/{path}; 'settings' is reserved by the host. */ + module: string + children?: PageChildContribution[] +} + export interface BootstrapCompletedEvent { adminMode: boolean companyId: number | null @@ -64,6 +86,7 @@ export interface InvoiceShelfExtensionApi { registerAdminSettingsNavigation(contribution: SettingsNavigationContribution): () => void registerCompanySettingsPage(contribution: SettingsPageContribution): () => void registerAdminSettingsPage(contribution: SettingsPageContribution): () => void + registerPage(contribution: PageContribution): () => void addMessages(messages: Record>): void notify(type: 'success' | 'error' | 'warning' | 'info', message: string): void on( diff --git a/src/Contracts/Host/CompanyDataReader.php b/src/Contracts/Host/CompanyDataReader.php index 2a33e3f..4bf02f4 100644 --- a/src/Contracts/Host/CompanyDataReader.php +++ b/src/Contracts/Host/CompanyDataReader.php @@ -5,7 +5,11 @@ namespace InvoiceShelf\Modules\Contracts\Host; /** - * Read-only company data required by the AI assistant's twelve built-in queries. + * Read-only, company-scoped queries the host exposes to official modules. + * + * Introduced for the AI assistant's built-in queries, the reader now also + * serves other official modules that need to read company data without + * touching host Eloquent models. * * Returned values are arrays of scalar data only. Hosts must not expose ORM * models, collections, or framework-specific value objects across this boundary. @@ -15,10 +19,19 @@ interface CompanyDataReader /** @return array */ public function companyStats(int $companyId, string $startDate, string $endDate): array; - /** @return array|null */ + /** + * The row carries the customer's `currency_id` and a `currency` sub-array + * shaped {id, code, symbol, precision}, or null when none is set. + * + * @return array|null + */ public function findCustomer(int $companyId, int $customerId): ?array; - /** @return array */ + /** + * Rows carry the customer's `currency_id`. + * + * @return array + */ public function searchCustomers(int $companyId, ?string $query, int $limit): array; /** @return array */ @@ -53,4 +66,13 @@ public function searchItems(int $companyId, ?string $query, int $limit): array; /** @return array */ public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array; + + /** @return list members of the company, ordered by name then id */ + public function companyMembers(int $companyId): array; + + /** + * @param list $invoiceIds + * @return list the subset of $invoiceIds that exist in the company + */ + public function existingInvoiceIds(int $companyId, array $invoiceIds): array; } diff --git a/src/Registry.php b/src/Registry.php index cf83bce..e24a714 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -80,6 +80,24 @@ class Registry */ public static array $drivers = []; + /** + * Ability registrations keyed by module slug, then by namespaced ability id. + * + * Each stored entry is normalized to the shape the host's role editor and + * CompanyService::setupRoles consume: + * - 'ability' (string) — '{slug}:{ability}', namespaced at registration + * - 'name' (string) — human label shown in the role editor + * - 'model' (null) — module abilities are never model-scoped + * - 'depends_on' (list) — abilities implied by this one + * - 'owner_only' (bool) — restrict the ability to the owner role + * + * The host grants these to owner roles when a module is enabled and drops + * them again on uninstall. + * + * @var array, owner_only: bool}>> + */ + public static array $abilities = []; + /** * Register a sidebar entry for a module. * @@ -358,6 +376,143 @@ public static function driverMeta(string $type, string $name): ?array return static::$drivers[$type][$name] ?? null; } + /** + * Register an ability a module contributes to the host's ability catalogue. + * + * Modules call this from their ServiceProvider::boot(): + * + * Registry::registerAbility('tasks-projects', [ + * 'ability' => 'view-project', + * 'name' => 'View Projects', + * 'depends_on' => ['view-customer', Registry::abilityId('tasks-projects', 'view-task')], + * ]); + * + * The ability is stored namespaced as '{slug}:{ability}' so module abilities + * can never collide with host abilities or with each other. Use abilityId() + * to build the same id for a frontend route's `meta.ability`. + * + * @param array{ability: string, name: string, depends_on?: list, model?: null, owner_only?: bool} $entry + * + * @throws InvalidArgumentException + */ + public static function registerAbility(string $slug, array $entry): void + { + $normalized = self::validateAbility($slug, $entry); + $id = $normalized['ability']; + + if ((static::$abilities[$slug][$id] ?? null) === $normalized) { + return; + } + + if (isset(static::$abilities[$slug][$id])) { + throw new InvalidArgumentException("Ability '{$id}' is already registered for module '{$slug}'."); + } + + static::$abilities[$slug][$id] = $normalized; + } + + /** + * @param array $entry + * @return array{ability: string, name: string, model: null, depends_on: list, owner_only: bool} + * + * @throws InvalidArgumentException + */ + private static function validateAbility(string $slug, array $entry): array + { + $kebab = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/'; + + if (! preg_match($kebab, $slug)) { + throw new InvalidArgumentException("Module slug '{$slug}' must be lower-case kebab-case, matching the module.json slug."); + } + + foreach (array_keys($entry) as $key) { + if (! in_array($key, ['ability', 'name', 'model', 'depends_on', 'owner_only'], true)) { + throw new InvalidArgumentException("Module '{$slug}' ability contains an unsupported key '{$key}'."); + } + } + + $ability = $entry['ability'] ?? null; + if (! is_string($ability) || $ability === '') { + throw new InvalidArgumentException("Module '{$slug}' must declare a non-empty string 'ability' key."); + } + if (str_contains($ability, ':')) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' must not contain a colon; the registry namespaces it as '{$slug}:{ability}'."); + } + if (! preg_match($kebab, $ability)) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' must be lower-case kebab-case."); + } + + $name = $entry['name'] ?? null; + if (! is_string($name) || trim($name) === '') { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' name must be a non-empty string."); + } + + if (array_key_exists('model', $entry) && $entry['model'] !== null) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' model must be absent or null; module abilities are never model-scoped."); + } + + $dependsOn = $entry['depends_on'] ?? []; + if (! is_array($dependsOn) || ! array_is_list($dependsOn)) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' depends_on must be a list of ability ids."); + } + foreach ($dependsOn as $dependency) { + if (! is_string($dependency) || ! preg_match('/^(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?[a-z0-9]+(?:-[a-z0-9]+)*$/', $dependency)) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' depends_on entries must be plain host ability ids or slug-namespaced module ability ids."); + } + } + + $ownerOnly = $entry['owner_only'] ?? false; + if (! is_bool($ownerOnly)) { + throw new InvalidArgumentException("Module '{$slug}' ability '{$ability}' owner_only must be a boolean."); + } + + return [ + 'ability' => static::abilityId($slug, $ability), + 'name' => $name, + 'model' => null, + 'depends_on' => $dependsOn, + 'owner_only' => $ownerOnly, + ]; + } + + /** + * Abilities registered by a single module, in registration order. + * + * @return list, owner_only: bool}> + */ + public static function abilitiesFor(string $slug): array + { + return array_values(static::$abilities[$slug] ?? []); + } + + /** + * Every registered module ability, in slug registration order. + * + * @return list, owner_only: bool}> + */ + public static function allAbilities(): array + { + $abilities = []; + + foreach (static::$abilities as $moduleAbilities) { + foreach ($moduleAbilities as $ability) { + $abilities[] = $ability; + } + } + + return $abilities; + } + + /** + * Build the namespaced id under which a module ability is registered. + * + * Frontend routes reference the same id through `meta.ability`. + */ + public static function abilityId(string $slug, string $ability): string + { + return "{$slug}:{$ability}"; + } + /** * Test-only: clear module-contributed state. * @@ -376,6 +531,7 @@ public static function flush(): void static::$settings = []; static::$scripts = []; static::$styles = []; + static::$abilities = []; } /** diff --git a/stubs/composer.stub b/stubs/composer.stub index 2be7f75..bb65fae 100644 --- a/stubs/composer.stub +++ b/stubs/composer.stub @@ -9,9 +9,9 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-json": "*", - "invoiceshelf/modules": "^3.3" + "invoiceshelf/modules": "^3.4" }, "require-dev": { "laravel/pint": "^1.16", diff --git a/stubs/json.stub b/stubs/json.stub index ad021b6..eba5f24 100644 --- a/stubs/json.stub +++ b/stubs/json.stub @@ -16,7 +16,7 @@ "license": "AGPL-3.0-only", "compatibility": { "invoiceshelf": "^3.0.0", - "module_api": "^1.2.0", + "module_api": "^1.3.0", "php": "^8.3.0", "extensions": [ "ext-json" @@ -29,7 +29,7 @@ "data_cleanup": "$MODULE_NAMESPACE$\\$STUDLY_NAME$\\Providers\\$STUDLY_NAME$ServiceProvider" }, "assets": [ - "dist/module.js", - "dist/module.css" + "dist/init.js", + "dist/style.css" ] } diff --git a/tests/AiContractsTest.php b/tests/AiContractsTest.php index 17e152c..adbd8d8 100644 --- a/tests/AiContractsTest.php +++ b/tests/AiContractsTest.php @@ -67,6 +67,7 @@ public function test_host_contracts_expose_the_exact_framework_neutral_ai_bounda [ 'companyStats', 'findCustomer', 'searchCustomers', 'rankCustomers', 'findInvoice', 'searchInvoices', 'overdueInvoices', 'recentPayments', 'expenseCategories', 'rankExpenseCategories', 'searchItems', 'rankItems', + 'companyMembers', 'existingInvoiceIds', ], array_map(static fn (ReflectionMethod $method): string => $method->getName(), (new \ReflectionClass(CompanyDataReader::class))->getMethods()), ); diff --git a/tests/ManifestTest.php b/tests/ManifestTest.php index 7e6732b..9d279e5 100644 --- a/tests/ManifestTest.php +++ b/tests/ManifestTest.php @@ -26,9 +26,9 @@ public function test_official_module_composer_stub_uses_the_reserved_package_and $this->assertSame('invoiceshelf/module-$KEBAB_NAME$', $stub['name']); $this->assertSame('AGPL-3.0-only', $stub['license']); - $this->assertSame('^8.3', $stub['require']['php']); + $this->assertSame('^8.4', $stub['require']['php']); $this->assertSame('*', $stub['require']['ext-json']); - $this->assertSame('^3.3', $stub['require']['invoiceshelf/modules']); + $this->assertSame('^3.4', $stub['require']['invoiceshelf/modules']); $this->assertSame('^11.0', $stub['require-dev']['orchestra/testbench']); $this->assertSame('^12.0', $stub['require-dev']['phpunit/phpunit']); $this->assertSame('vendor/bin/pint --test', $stub['scripts']['lint']); @@ -52,7 +52,8 @@ public function test_schema_v2_stubs_declare_a_compatible_cleanup_provider(): vo $stub = json_decode($json, true, 512, JSON_THROW_ON_ERROR); $this->assertSame(2, $stub['schema_version']); - $this->assertSame('^1.2.0', $stub['compatibility']['module_api']); + $this->assertSame('^1.3.0', $stub['compatibility']['module_api']); + $this->assertSame(['dist/init.js', 'dist/style.css'], $stub['assets']); $this->assertSame('reversible', $stub['migration_policy']); $this->assertSame('$MODULE_NAMESPACE$\\$STUDLY_NAME$\\Providers\\$STUDLY_NAME$ServiceProvider', $stub['uninstall']['data_cleanup']); $this->assertStringContainsString('implements DataCleanup', $provider); @@ -77,6 +78,7 @@ public function test_sdk_publishes_the_typed_frontend_extension_contract(): void $this->assertStringContainsString('registerHeaderAction', $types); $this->assertStringContainsString('registerRichEditorToolbarAction', $types); $this->assertStringContainsString('registerCompanySettingsPage', $types); + $this->assertStringContainsString('registerPage(contribution: PageContribution)', $types); $this->assertStringContainsString("'company:changed'", $types); } diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php index 7321e3c..89879f6 100644 --- a/tests/RegistryTest.php +++ b/tests/RegistryTest.php @@ -454,6 +454,122 @@ public function test_flush_drivers_clears_driver_registrations(): void $this->assertSame([], Registry::allDrivers('exchange_rate')); } + + public function test_register_ability_round_trip(): void + { + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'View Projects']); + Registry::registerAbility('tasks-projects', ['ability' => 'manage-project', 'name' => 'Manage Projects', 'owner_only' => true]); + Registry::registerAbility('stock-control', ['ability' => 'adjust-stock', 'name' => 'Adjust Stock', 'depends_on' => ['view-item']]); + + $this->assertSame( + [ + 'ability' => 'tasks-projects:view-project', + 'name' => 'View Projects', + 'model' => null, + 'depends_on' => [], + 'owner_only' => false, + ], + Registry::abilitiesFor('tasks-projects')[0], + ); + $this->assertSame( + ['ability', 'name', 'model', 'depends_on', 'owner_only'], + array_keys(Registry::abilitiesFor('tasks-projects')[0]), + ); + $this->assertSame( + ['tasks-projects:view-project', 'tasks-projects:manage-project'], + array_column(Registry::abilitiesFor('tasks-projects'), 'ability'), + ); + $this->assertTrue(Registry::abilitiesFor('tasks-projects')[1]['owner_only']); + $this->assertSame(['view-item'], Registry::abilitiesFor('stock-control')[0]['depends_on']); + $this->assertSame( + ['tasks-projects:view-project', 'tasks-projects:manage-project', 'stock-control:adjust-stock'], + array_column(Registry::allAbilities(), 'ability'), + ); + } + + public function test_ability_id_namespaces_an_ability_with_its_module_slug(): void + { + $this->assertSame('tasks-projects:view-project', Registry::abilityId('tasks-projects', 'view-project')); + } + + public function test_ability_registration_is_idempotent_for_an_identical_entry(): void + { + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'View Projects']); + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'View Projects']); + + $this->assertCount(1, Registry::abilitiesFor('tasks-projects')); + } + + public function test_ability_rejects_a_conflicting_duplicate_without_replacing_the_first_registration(): void + { + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'View Projects']); + + try { + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'Browse Projects']); + $this->fail('Expected a conflicting ability registration to throw.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString("tasks-projects:view-project' is already registered", $exception->getMessage()); + $this->assertStringContainsString("module 'tasks-projects'", $exception->getMessage()); + } + + $this->assertSame('View Projects', Registry::abilitiesFor('tasks-projects')[0]['name']); + } + + public function test_ability_depends_on_accepts_plain_host_and_namespaced_module_ids(): void + { + Registry::registerAbility('tasks-projects', [ + 'ability' => 'view-project', + 'name' => 'View Projects', + 'depends_on' => ['view-customer', Registry::abilityId('tasks-projects', 'view-task')], + ]); + + $this->assertSame( + ['view-customer', 'tasks-projects:view-task'], + Registry::abilitiesFor('tasks-projects')[0]['depends_on'], + ); + } + + #[DataProvider('invalidAbilityRegistrations')] + public function test_register_ability_rejects_malformed_entries(string $slug, array $entry, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Registry::registerAbility($slug, $entry); + } + + /** @return iterable, string}> */ + public static function invalidAbilityRegistrations(): iterable + { + $valid = ['ability' => 'view-project', 'name' => 'View Projects']; + + yield 'invalid slug' => ['Tasks_Projects', $valid, "Module slug 'Tasks_Projects' must be lower-case kebab-case"]; + yield 'missing ability' => ['tasks-projects', ['name' => 'View Projects'], "must declare a non-empty string 'ability' key"]; + yield 'namespaced ability' => ['tasks-projects', array_replace($valid, ['ability' => 'tasks-projects:view-project']), 'must not contain a colon']; + yield 'uppercase ability' => ['tasks-projects', array_replace($valid, ['ability' => 'viewProject']), "'viewProject' must be lower-case kebab-case"]; + yield 'blank name' => ['tasks-projects', array_replace($valid, ['name' => ' ']), 'name must be a non-empty string']; + yield 'model-scoped ability' => ['tasks-projects', array_replace($valid, ['model' => 'App\Models\Project']), 'model must be absent or null']; + yield 'unknown key' => ['tasks-projects', array_replace($valid, ['group' => 'projects']), "unsupported key 'group'"]; + yield 'non-list depends_on' => ['tasks-projects', array_replace($valid, ['depends_on' => ['project' => 'view-project']]), 'depends_on must be a list of ability ids']; + yield 'invalid depends_on entry' => ['tasks-projects', array_replace($valid, ['depends_on' => ['View_Project']]), 'depends_on entries must be plain host ability ids']; + yield 'non-bool owner_only' => ['tasks-projects', array_replace($valid, ['owner_only' => 'yes']), 'owner_only must be a boolean']; + } + + public function test_abilities_for_an_unknown_slug_is_empty(): void + { + $this->assertSame([], Registry::abilitiesFor('unknown')); + $this->assertSame([], Registry::allAbilities()); + } + + public function test_flush_clears_abilities(): void + { + Registry::registerAbility('tasks-projects', ['ability' => 'view-project', 'name' => 'View Projects']); + + Registry::flush(); + + $this->assertSame([], Registry::allAbilities()); + $this->assertSame([], Registry::abilitiesFor('tasks-projects')); + } } class FakeAiDriver extends AiDriver From fb7b62961153a42a7c12aabed795cd4107e4ad0d Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 16:50:56 +0200 Subject: [PATCH 2/4] chore: align json stub PHP pin and tidy ability docblock The scaffold's module.json now declares php ^8.4.0 to match the composer stub, and the ability registry docblock uses plain punctuation. --- src/Registry.php | 10 +++++----- stubs/json.stub | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Registry.php b/src/Registry.php index e24a714..1a73801 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -85,11 +85,11 @@ class Registry * * Each stored entry is normalized to the shape the host's role editor and * CompanyService::setupRoles consume: - * - 'ability' (string) — '{slug}:{ability}', namespaced at registration - * - 'name' (string) — human label shown in the role editor - * - 'model' (null) — module abilities are never model-scoped - * - 'depends_on' (list) — abilities implied by this one - * - 'owner_only' (bool) — restrict the ability to the owner role + * - 'ability' (string) '{slug}:{ability}', namespaced at registration + * - 'name' (string) human label shown in the role editor + * - 'model' (null) module abilities are never model-scoped + * - 'depends_on' (list) abilities implied by this one + * - 'owner_only' (bool) restrict the ability to the owner role * * The host grants these to owner roles when a module is enabled and drops * them again on uninstall. diff --git a/stubs/json.stub b/stubs/json.stub index eba5f24..6847949 100644 --- a/stubs/json.stub +++ b/stubs/json.stub @@ -17,7 +17,7 @@ "compatibility": { "invoiceshelf": "^3.0.0", "module_api": "^1.3.0", - "php": "^8.3.0", + "php": "^8.4.0", "extensions": [ "ext-json" ] From 84bbfcfdf2cbbd55ecdcb65a3f37a84d3b2d6d92 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:55:37 +0200 Subject: [PATCH 3/4] feat: validate and document sidebar placement of module menu entries registerMenu and registerUserMenu now reject a non-integer priority or an empty group, and the README explains how group and priority place an entry among the core sidebar groups. --- README.md | 21 +++++++++++++++++++++ src/Registry.php | 24 ++++++++++++++++++++++++ tests/RegistryTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/README.md b/README.md index e706044..a98f4fc 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,27 @@ Registry::registerMenu('sales-tax-us', [ ]); ``` +### Sidebar placement + +`registerMenu` and `registerUserMenu` accept two placement keys besides `title`, `link` and `icon`: + +- `group`: the sidebar group the entry joins. The default is `modules`, rendered last with the + "Modules" label. A module may join a core group instead: `main` (Dashboard 10, Customers 20, + Items 30), `documents` (Estimates 10, Invoices 20, Payments 30, Expenses 40) or `admin` + (Members 20, Reports 30, Settings 40). +- `priority`: an integer; lower sorts first inside the group, default `100`. Entries with equal + priority keep registration order, so official modules set explicit values (`10`, `20`, ...). + Groups are ordered by the lowest priority they contain. + +```php +Registry::registerMenu('tasks-projects', [ + 'title' => 'tasksprojects::menu.title', + 'link' => '/admin/modules/tasks-projects', + 'icon' => 'ClipboardDocumentListIcon', + 'priority' => 10, +]); +``` + ### Abilities `Registry::registerAbility()` adds a module's own permissions to the host's ability catalogue. Every diff --git a/src/Registry.php b/src/Registry.php index 1a73801..919f159 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -105,6 +105,8 @@ class Registry */ public static function registerMenu(string $slug, array $item): void { + self::validateMenuItem($slug, $item); + static::$menu[$slug] = array_merge([ 'group' => 'modules', 'group_label' => 'navigation.modules', @@ -112,6 +114,26 @@ public static function registerMenu(string $slug, array $item): void ], $item); } + /** + * Placement keys are optional, but when present they must be usable by the + * host sidebar: `priority` is an integer (lower sorts first inside a group, + * default 100) and `group` is a non-empty string naming a sidebar group. + * + * @param array $item + * + * @throws InvalidArgumentException + */ + private static function validateMenuItem(string $slug, array $item): void + { + if (array_key_exists('priority', $item) && ! is_int($item['priority'])) { + throw new InvalidArgumentException("Menu entry '{$slug}' priority must be an integer."); + } + + if (array_key_exists('group', $item) && (! is_string($item['group']) || trim($item['group']) === '')) { + throw new InvalidArgumentException("Menu entry '{$slug}' group must be a non-empty string."); + } + } + /** * Register a settings schema for a module. * @@ -154,6 +176,8 @@ public static function menuFor(string $slug): ?array */ public static function registerUserMenu(string $slug, array $item): void { + self::validateMenuItem($slug, $item); + static::$userMenu[$slug] = array_merge([ 'priority' => 100, ], $item); diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php index 89879f6..e8d41d5 100644 --- a/tests/RegistryTest.php +++ b/tests/RegistryTest.php @@ -570,6 +570,30 @@ public function test_flush_clears_abilities(): void $this->assertSame([], Registry::allAbilities()); $this->assertSame([], Registry::abilitiesFor('tasks-projects')); } + + public function test_menu_placement_keys_are_kept_and_validated(): void + { + Registry::registerMenu('placed', ['title' => 'Placed', 'link' => '/admin/modules/placed', 'icon' => 'FolderIcon', 'group' => 'documents', 'priority' => 25]); + + self::assertSame('documents', Registry::menuFor('placed')['group']); + self::assertSame(25, Registry::menuFor('placed')['priority']); + } + + public function test_menu_priority_must_be_an_integer(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Menu entry 'bad' priority must be an integer."); + + Registry::registerMenu('bad', ['title' => 'Bad', 'link' => '/x', 'icon' => 'FolderIcon', 'priority' => '10']); + } + + public function test_user_menu_group_must_be_a_non_empty_string(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Menu entry 'bad' group must be a non-empty string."); + + Registry::registerUserMenu('bad', ['title' => 'Bad', 'link' => '/x', 'icon' => 'FolderIcon', 'group' => '']); + } } class FakeAiDriver extends AiDriver From 24c1368fd72bcfab69819045d4082a286e0ddbfc Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 08:06:13 +0200 Subject: [PATCH 4/4] docs: sidebar groups keep the host order; priority sorts inside a group --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a98f4fc..9ce5a2d 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ Registry::registerMenu('sales-tax-us', [ (Members 20, Reports 30, Settings 40). - `priority`: an integer; lower sorts first inside the group, default `100`. Entries with equal priority keep registration order, so official modules set explicit values (`10`, `20`, ...). - Groups are ordered by the lowest priority they contain. + Groups keep the order the host sends them in: the core groups first, then module groups in + registration order, so a low priority never lifts a module group above the core ones. ```php Registry::registerMenu('tasks-projects', [