From 68cc59586afd8f9411eb6ad35fc867aab399b40b Mon Sep 17 00:00:00 2001 From: raftaar1191 Date: Wed, 24 Jun 2026 20:21:22 +0530 Subject: [PATCH] feat(db,rest,ui)!: per-consumer table + slug-scoped REST routes (v2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: each consumer plugin now owns its own database table, object-cache group, and REST route prefix. AccessControlManager requires a second constructor argument $table_slug (^[a-z0-9_]{1,32}$). Existing v1.x consumers that pass only the providers filter tag will fail with ArgumentCountError on upgrade. Previously every consumer wrote into one shared {prefix}wpb_access_control table and registered the same /wpb-ac/v1/... routes — two plugins on the same WordPress install would collide on rules, on cached payloads (single wpb_access_control cache group), and on REST handlers (last-loaded-wins). Migration: // PHP bootstrap $manager = new AccessControlManager( 'my_plugin_access_control_providers', 'my_plugin' // slug; pick one per plugin ); // wpbAcConfig / React props wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [ 'pluginSlug' => 'my_plugin', // NEW // …existing fields… ] ); // REST URLs: /wpb-ac/v1/rules/... → /wpb-ac/v1/{slug}/rules/... Optional one-off SQL copy from {prefix}wpb_access_control to the new per-consumer table; see README's "Upgrading from 1.x" section. The library does NOT auto-migrate — it doesn't know which namespaces each consumer owns. Files: - src/Slug.php — new helper, Slug::sanitize() validates ^[a-z0-9_]{1,32}$ and throws \InvalidArgumentException on invalid input. - src/Database/Rule/RuleTable.php — constructor accepts $table_slug; derives $name and $db_version_key per instance; removes hardcoded defaults. - src/Database/Rule/RuleQuery.php — constructor accepts $table_slug; derives $table_name and $cache_group; replaces single static $table_setup flag with per-slug registry; folds slug into the transient_key hash so cache cannot collide across consumers. - src/AccessControlManager.php — second required constructor arg $table_slug; new get_table_slug() getter; passes the slug into RuleQuery + RulesController. - src/RestApi/RulesController.php — accepts slug; every register_rest_route call now uses '/{slug}/rules/…', '/{slug}/providers', '/{slug}/users', '/{slug}/namespaces/{namespace}'. - js/AccessControl.js + js/components/UserSearchPanel.js + js/index.js — new required pluginSlug prop / wpbAcConfig field; every apiFetch URL carries the slug segment. Auto-render path bails with a clear console error when pluginSlug is missing. - tests: new SlugTest (17 cases covering accept/reject + error message shape), new RuleQueryConstructorTest (8 cases asserting slug-driven table_name / cache_group / two-instance isolation + slug validation propagation through the ctor), 2 new register_routes tests in RulesControllerTest (slug prefix on all four route groups + distinct prefix per slug). Existing tests untouched — Brain Monkey suites use newInstanceWithoutConstructor / disableOriginalConstructor / partial mocks, so the new required ctor arg is transparent to them. PHPUnit: 190 tests / 267 assertions all green (was 160/218). JS assets rebuilt via wp-scripts. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 64 ++++++++ README.md | 153 ++++++++++++++---- assets/build/index.asset.php | 2 +- assets/build/index.js | 2 +- js/AccessControl.js | 18 ++- js/components/UserSearchPanel.js | 6 +- js/index.js | 40 +++-- src/AccessControlManager.php | 41 ++++- src/Database/Rule/RuleQuery.php | 74 +++++++-- src/Database/Rule/RuleTable.php | 52 +++++- src/RestApi/RulesController.php | 40 +++-- src/Slug.php | 63 ++++++++ .../Rule/RuleQueryConstructorTest.php | 124 ++++++++++++++ tests/Unit/RestApi/RulesControllerTest.php | 43 +++++ tests/Unit/SlugTest.php | 88 ++++++++++ 15 files changed, 717 insertions(+), 93 deletions(-) create mode 100644 src/Slug.php create mode 100644 tests/Unit/Database/Rule/RuleQueryConstructorTest.php create mode 100644 tests/Unit/SlugTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ba00162..e3e30b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## 2.0.0 + +**BREAKING.** Each consumer plugin now owns its own database table, object-cache group, and REST route prefix. Previously every consumer wrote into the single shared `{prefix}wpb_access_control` table — two plugins on the same WordPress install collided on storage, cache, and routes. + +### Required code changes for v1.x → 2.0 + +1. **Add a slug to the manager constructor.** Both args are now required: + + ```php + // Before + $manager = new AccessControlManager( 'my_plugin_access_control_providers' ); + + // After (slug must match ^[a-z0-9_]{1,32}$) + $manager = new AccessControlManager( + 'my_plugin_access_control_providers', + 'my_plugin' + ); + ``` + +2. **Add `pluginSlug` to `wpbAcConfig`** (or pass it as a prop when importing the component directly): + + ```php + wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [ + 'pluginSlug' => 'my_plugin', // NEW: must match the PHP slug + // …existing fields… + ] ); + ``` + +3. **REST URLs gain a slug segment.** Every endpoint is now under `/wpb-ac/v1/{slug}/...`. cURL / api-fetch callers must update their paths: + + ```diff + - GET /wpb-ac/v1/rules/{namespace}/{key} + + GET /wpb-ac/v1/{slug}/rules/{namespace}/{key} + - GET /wpb-ac/v1/providers + + GET /wpb-ac/v1/{slug}/providers + - GET /wpb-ac/v1/users?search=... + + GET /wpb-ac/v1/{slug}/users?search=... + - DELETE /wpb-ac/v1/namespaces/{namespace} + + DELETE /wpb-ac/v1/{slug}/namespaces/{namespace} + ``` + +4. **Optional one-off data migration.** Existing rows stay in `{prefix}wpb_access_control` until each consumer copies them out: + + ```sql + INSERT INTO {prefix}my_plugin_access_control + SELECT * FROM {prefix}wpb_access_control + WHERE namespace IN ( 'your-namespace-1', 'your-namespace-2' ); + ``` + + The library does NOT auto-migrate — it doesn't know which consumer owns which namespace. + +### Why + +Plugins embedding the library via Composer were silently sharing one table and one REST surface. Two plugins on the same site would collide on rules, on cached payloads (single `wpb_access_control` cache group), and on registered REST routes (last-loaded-wins). + +### Changes + +- feat(db): per-consumer table — `{prefix}{slug}_access_control` driven by the new required `$table_slug` constructor argument +- feat(db): per-consumer object-cache group (`wpb_ac_{slug}`) and transient prefix +- feat(db): per-slug `db_version_key` option (`wpb_ac_{slug}_db_version`) +- feat(rest): slug-scoped routes under `/wpb-ac/v1/{slug}/...` +- feat(ui): React component requires a new `pluginSlug` prop; `wpbAcConfig.pluginSlug` is required by the auto-render path +- feat(slug): new `WPBoilerplate\AccessControl\Slug` helper validates `^[a-z0-9_]{1,32}$` and throws `\InvalidArgumentException` on invalid input — applied consistently across `RuleTable`, `RuleQuery`, and `AccessControlManager` + ## 1.6.0 **BREAKING (BuddyBoss / MemberPress providers):** the `BuddyBossProfileTypeProvider` and `MemberPressMembershipProvider` are now opt-in. Each provider's `is_available()` consults a new filter that defaults to `false`, so the provider is hidden from the React dropdown and denies on every check until the consumer plugin explicitly opts in. Existing rules saved against either provider deny by default after upgrading until the corresponding filter is hooked. diff --git a/README.md b/README.md index 6ae0e71..7a535a6 100644 --- a/README.md +++ b/README.md @@ -83,26 +83,37 @@ require_once __DIR__ . '/vendor/autoload_packages.php'; ### 1. Boot the manager Declare `$manager` at **file scope** (outside any closure) so every subsequent -hook can capture it via `use`. Always pass a **plugin-specific filter tag** to -prevent your providers bleeding into other plugins that also use this library. +hook can capture it via `use`. Pass two arguments: + +1. A **plugin-specific provider filter tag** so your providers don't bleed + into other plugins using the library. +2. A **per-plugin table slug** (must match `^[a-z0-9_]{1,32}$`) so your + database table, object-cache group, and REST routes are isolated from + every other plugin embedding the library. ```php use WPBoilerplate\AccessControl\AccessControlManager; // File scope — available to all hooks below via `use ( $manager )`. -$manager = new AccessControlManager( 'my_plugin_access_control_providers' ); +$manager = new AccessControlManager( + 'my_plugin_access_control_providers', // provider filter tag + 'my_plugin' // table slug (required) +); ``` `AccessControlManager` owns a `RuleQuery` internally. Instantiating it registers `RuleTable` via BerlinDB, which creates or upgrades the -`{prefix}wpb_access_control` table automatically on `admin_init`. +`{prefix}my_plugin_access_control` table automatically on `admin_init`. > **Need to wait for other plugins first?** Use a reference capture instead: > > ```php > $manager = null; > add_action( 'plugins_loaded', function () use ( &$manager ) { -> $manager = new AccessControlManager( 'my_plugin_access_control_providers' ); +> $manager = new AccessControlManager( +> 'my_plugin_access_control_providers', +> 'my_plugin' +> ); > } ); > // All subsequent hooks must also use `&$manager`. > ``` @@ -138,7 +149,10 @@ use WPBoilerplate\AccessControl\AccessControlManager; require_once __DIR__ . '/vendor/autoload_packages.php'; // 2. Create the manager at file scope — captured by all hooks via `use ( $manager )`. -$manager = new AccessControlManager( 'my_plugin_access_control_providers' ); +$manager = new AccessControlManager( + 'my_plugin_access_control_providers', // provider filter tag + 'my_plugin' // table slug (required) +); // 3. Expose the REST API. add_action( 'rest_api_init', function () use ( $manager ) { @@ -190,6 +204,7 @@ add_action( 'admin_enqueue_scripts', function ( string $hook ) use ( &$settings_ // Pass config to the component via window.wpbAcConfig. wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [ + 'pluginSlug' => 'my_plugin', // required — must match the PHP table slug 'namespace' => 'my-plugin', 'resourceKey' => 'settings-page', 'restApiRoot' => get_rest_url(), @@ -373,6 +388,7 @@ add_action( 'admin_enqueue_scripts', function ( string $hook ) use ( $page_hook // Pass configuration to the component via window.wpbAcConfig. wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [ + 'pluginSlug' => 'my_plugin', // required — must match the PHP table slug 'namespace' => 'my-namespace', 'resourceKey' => 'my-resource', 'restApiRoot' => get_rest_url(), @@ -400,7 +416,8 @@ add_action( 'my_plugin_settings_page', function () { | Prop | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `namespace` | `string` | ✅ | — | Access-control namespace, e.g. `"mcp"` | +| `pluginSlug` | `string` | ✅ | — | Consumer slug — must match the PHP `table_slug`. Used to build every REST URL (`/wpb-ac/v1/{pluginSlug}/...`). | +| `namespace` | `string` | ✅ | — | Resource namespace, e.g. `"mcp"` | | `resourceKey` | `string` | ✅ | — | Resource key within the namespace | | `restApiRoot` | `string` | ✅ | — | WP REST API root URL (`get_rest_url()`) | | `nonce` | `string` | ✅ | — | `wp_create_nonce('wp_rest')` | @@ -424,6 +441,7 @@ apiFetch.use( apiFetch.createNonceMiddleware( wpbAcConfig.nonce ) ); import { createRoot } from '@wordpress/element'; createRoot( document.getElementById( 'my-ac-panel' ) ).render( get_query()->get_rule( 'my-namespace', 'my-resource' ); ## REST API -REST namespace: **`wpb-ac/v1`** +REST namespace: **`wpb-ac/v1`**. Every route is scoped under the consumer's +table slug — `{slug}` in the paths below is the same string you pass to +`new AccessControlManager(...)`. All endpoints require `manage_options` (administrator) by default. Use the `wpb_access_control_rest_permission` filter to override. @@ -491,16 +511,16 @@ Use the `wpb_access_control_rest_permission` filter to override. | Method | Path | Description | |--------|------|-------------| -| `GET` | `/rules/{namespace}/{key}` | Read the current rule | -| `PUT` | `/rules/{namespace}/{key}` | Create or replace a rule | -| `DELETE` | `/rules/{namespace}/{key}` | Clear a rule (revert to unrestricted) | -| `DELETE` | `/namespaces/{namespace}` | Purge all rules for a namespace | -| `GET` | `/providers` | List registered providers and their options | -| `GET` | `/users?search=...&limit=10` | Search WordPress users | +| `GET` | `/{slug}/rules/{namespace}/{key}` | Read the current rule | +| `PUT` | `/{slug}/rules/{namespace}/{key}` | Create or replace a rule | +| `DELETE` | `/{slug}/rules/{namespace}/{key}` | Clear a rule (revert to unrestricted) | +| `DELETE` | `/{slug}/namespaces/{namespace}` | Purge all rules for a namespace | +| `GET` | `/{slug}/providers` | List registered providers and their options | +| `GET` | `/{slug}/users?search=...&limit=10` | Search WordPress users | > **Slashes in namespace**: The `{namespace}` URL segment cannot contain > literal slashes — encode them as `%2F`: -> `.../rules/procureco%2Fv1/my-key`. +> `.../my_plugin/rules/procureco%2Fv1/my-key`. > The `{key}` segment allows literal slashes. ### Request / response shapes @@ -570,28 +590,28 @@ Authorization: Basic base64(username:application_password) #### cURL ```bash -# Read +# Read (replace 'my_plugin' with your slug throughout) curl -H "X-WP-Nonce: " \ - https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource + https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource # Set curl -X PUT \ -H "X-WP-Nonce: " \ -H "Content-Type: application/json" \ -d '{"ac_key":"wp_role","ac_options":["editor","author"]}' \ - https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource + https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource # Namespace with slashes curl -X PUT \ -H "X-WP-Nonce: " \ -H "Content-Type: application/json" \ -d '{"ac_key":"wp_role","ac_options":["editor"]}' \ - https://example.com/wp-json/wpb-ac/v1/rules/procureco%2Fv1/endpoints%2Flist + https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/procureco%2Fv1/endpoints%2Flist # Clear curl -X DELETE \ -H "X-WP-Nonce: " \ - https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource + https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource ``` #### PHP (`wp_remote_request`) @@ -599,14 +619,14 @@ curl -X DELETE \ ```php // Read $response = wp_remote_get( - rest_url( 'wpb-ac/v1/rules/my-namespace/my-resource' ), + rest_url( 'wpb-ac/v1/my_plugin/rules/my-namespace/my-resource' ), [ 'headers' => [ 'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ) ] ] ); $rule = json_decode( wp_remote_retrieve_body( $response ), true ); // Set wp_remote_request( - rest_url( 'wpb-ac/v1/rules/my-namespace/my-resource' ), + rest_url( 'wpb-ac/v1/my_plugin/rules/my-namespace/my-resource' ), [ 'method' => 'PUT', 'headers' => [ @@ -623,28 +643,31 @@ wp_remote_request( ```js import apiFetch from '@wordpress/api-fetch'; +const slug = 'my_plugin'; // must match the PHP table slug + // Read -const rule = await apiFetch( { path: '/wpb-ac/v1/rules/my-namespace/my-resource' } ); +const rule = await apiFetch( { path: `/wpb-ac/v1/${slug}/rules/my-namespace/my-resource` } ); // Set await apiFetch( { - path: '/wpb-ac/v1/rules/my-namespace/my-resource', + path: `/wpb-ac/v1/${slug}/rules/my-namespace/my-resource`, method: 'PUT', data: { ac_key: 'wp_role', ac_options: [ 'editor', 'author' ] }, } ); // Search users (for the wp_user provider UI) -const users = await apiFetch( { path: '/wpb-ac/v1/users?search=jane&limit=10' } ); +const users = await apiFetch( { path: `/wpb-ac/v1/${slug}/users?search=jane&limit=10` } ); // List providers (for building a custom UI) -const providers = await apiFetch( { path: '/wpb-ac/v1/providers' } ); +const providers = await apiFetch( { path: `/wpb-ac/v1/${slug}/providers` } ); ``` #### Vanilla `fetch` ```js const nonce = document.querySelector( 'meta[name="wp-rest-nonce"]' )?.content; -const apiUrl = '/wp-json/wpb-ac/v1'; +const slug = 'my_plugin'; +const apiUrl = `/wp-json/wpb-ac/v1/${slug}`; // Read const rule = await fetch( `${apiUrl}/rules/my-namespace/my-resource`, { @@ -922,7 +945,13 @@ the consuming plugin. ## Database Table Reference -Table: `{prefix}wpb_access_control` · DB layer: BerlinDB `^2.0` · Schema version: `202605120001` +Table: `{prefix}{slug}_access_control` — one per consumer (e.g. +`wp_mcp_access_control`, `wp_abilities_access_control`). +DB layer: BerlinDB `^3.0` · Schema version: `202605120001` + +Each consumer's table is created on the first `admin_init` after the +manager is instantiated. The schema is identical across consumers; only +the name and the `wpb_ac_{slug}_db_version` option differ. | Column | Type | Notes | |--------|------|-------| @@ -944,3 +973,71 @@ Indexes: `PRIMARY KEY (id)` · `UNIQUE (namespace, key(191), access_control_valu | `everyone` | One row: `access_control_key='everyone'`, `access_control_value=''` | | `wp_role` + `['editor','author']` | Two rows, both `access_control_key='wp_role'`; values `'editor'`, `'author'` | | `wp_user` + `['1','42']` | Two rows, both `access_control_key='wp_user'`; values `'1'`, `'42'` | + +--- + +## Upgrading from 1.x + +v2.0.0 introduces a required `$table_slug` constructor argument. Each +consumer plugin now owns its own table, object-cache group, and REST +route prefix — fixing the silent collision when two plugins embed the +library on the same WordPress install. + +### 1. Update the manager constructor + +```diff +- $manager = new AccessControlManager( 'my_plugin_access_control_providers' ); ++ $manager = new AccessControlManager( ++ 'my_plugin_access_control_providers', ++ 'my_plugin' // table slug: ^[a-z0-9_]{1,32}$ ++ ); +``` + +Invalid slugs throw `\InvalidArgumentException` immediately. + +### 2. Update `wpbAcConfig` / React props + +```diff + wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [ ++ 'pluginSlug' => 'my_plugin', + 'namespace' => 'my-namespace', + 'resourceKey' => 'my-resource', + // … + ] ); +``` + +### 3. Update REST URLs + +Every endpoint moves under `/wpb-ac/v1/{slug}/...`: + +```diff +- GET /wpb-ac/v1/rules/{namespace}/{key} ++ GET /wpb-ac/v1/{slug}/rules/{namespace}/{key} + +- GET /wpb-ac/v1/providers ++ GET /wpb-ac/v1/{slug}/providers + +- GET /wpb-ac/v1/users?search=... ++ GET /wpb-ac/v1/{slug}/users?search=... + +- DELETE /wpb-ac/v1/namespaces/{namespace} ++ DELETE /wpb-ac/v1/{slug}/namespaces/{namespace} +``` + +### 4. (Optional) Migrate existing rows + +The library **does not** auto-migrate from `{prefix}wpb_access_control`. +Run a one-off SQL copy from your plugin's update routine, filtering by +the namespaces *your* plugin owns: + +```sql +INSERT INTO {prefix}my_plugin_access_control + ( namespace, `key`, access_control_key, access_control_value, created_at, updated_at ) +SELECT + namespace, `key`, access_control_key, access_control_value, created_at, updated_at +FROM {prefix}wpb_access_control +WHERE namespace IN ( 'your-namespace-1', 'your-namespace-2' ); +``` + +Drop the legacy table only after **every** consumer plugin on the site +has upgraded — otherwise an un-upgraded plugin will lose its rules. diff --git a/assets/build/index.asset.php b/assets/build/index.asset.php index 0d57e6c..89e360b 100644 --- a/assets/build/index.asset.php +++ b/assets/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => 'dd14e0948119b5b0ee35'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => '61d2200a81dc17d89e02'); diff --git a/assets/build/index.js b/assets/build/index.js index 50f8a2f..8f131e7 100644 --- a/assets/build/index.js +++ b/assets/build/index.js @@ -1 +1 @@ -(()=>{"use strict";var e={n:s=>{var a=s&&s.__esModule?()=>s.default:()=>s;return e.d(a,{a}),a},d:(s,a)=>{for(var c in a)e.o(a,c)&&!e.o(s,c)&&Object.defineProperty(s,c,{enumerable:!0,get:a[c]})},o:(e,s)=>Object.prototype.hasOwnProperty.call(e,s)};const s=window.wp.element,a=window.wp.apiFetch;var c=e.n(a);const n=window.ReactJSXRuntime,l=[{value:"",label:"No user access added by admin"},{value:"everyone",label:"Everyone (no restriction)"}];function t({providers:e,value:s,onChange:a}){const c=e.filter(e=>!1!==e.available);return(0,n.jsxs)("select",{className:"wpb-ac__select",value:s,onChange:e=>a(e.target.value),children:[l.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value)),c.map(e=>(0,n.jsx)("option",{value:e.id,children:e.label},e.id))]})}const i={wp_role:"Select which WordPress Role values may access this resource. Leave all unchecked to deny everyone (except administrators).",wp_capability:"Select which WordPress capabilities grant access. Users holding any of the checked capabilities are allowed. Administrators always have access.",bb_profile_type:"Select which BuddyBoss profile types grant access. Users assigned to any of the checked profile types are allowed. Administrators always have access.",mepr_membership:"Select which MemberPress memberships grant access. Users with an active subscription to any of the checked memberships are allowed. Administrators always have access."};function r({providerId:e,options:s,selectedOptions:a,onToggle:c}){const l=i[e]||null;return(0,n.jsxs)("div",{className:"wpb-ac__options-panel",children:[l&&(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:l}),(0,n.jsx)("ul",{className:"wpb-ac__checkbox-list",children:s.map(e=>(0,n.jsx)("li",{className:"wpb-ac__checkbox-item",children:(0,n.jsxs)("label",{children:[(0,n.jsx)("input",{type:"checkbox",value:e.id,checked:a.includes(e.id),onChange:()=>c(e.id)}),e.label]})},e.id))})]})}function o({restApiRoot:e,selectedUsers:a,onAdd:l,onRemove:t}){const[i,r]=(0,s.useState)(""),[o,d]=(0,s.useState)([]),[p,u]=(0,s.useState)(!1),h=(0,s.useRef)(null),m=(0,s.useRef)(null);(0,s.useEffect)(()=>{const e=e=>{m.current&&!m.current.contains(e.target)&&u(!1)};return document.addEventListener("mousedown",e),()=>document.removeEventListener("mousedown",e)},[]);const b=(0,s.useCallback)(s=>{if(!s.trim())return d([]),void u(!1);c()({url:`${e}/wpb-ac/v1/users?search=${encodeURIComponent(s)}`}).then(e=>{d(e||[]),u(!0)}).catch(()=>d([]))},[e]),w=(0,s.useCallback)(e=>{const s=e.target.value;r(s),clearTimeout(h.current),h.current=setTimeout(()=>b(s),300)},[b]),_=(0,s.useCallback)(e=>{l({id:e.id,login:e.login,display_name:e.display_name}),r(""),d([]),u(!1)},[l]),v=o.filter(e=>!a.find(s=>s.id===e.id));return(0,n.jsxs)("div",{className:"wpb-ac__user-panel",ref:m,children:[(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:"Search by username or email and select one or more users. Administrators always have access regardless of this list."}),(0,n.jsxs)("div",{className:"wpb-ac__search-wrapper",children:[(0,n.jsx)("input",{type:"text",className:"wpb-ac__search-input",placeholder:"Search by username or email...",value:i,onChange:w,onFocus:()=>v.length&&u(!0)}),p&&v.length>0&&(0,n.jsx)("ul",{className:"wpb-ac__search-dropdown",children:v.map(e=>(0,n.jsxs)("li",{className:"wpb-ac__search-option",onMouseDown:()=>_(e),children:[e.display_name," (",e.login,")"]},e.id))})]}),a.length>0&&(0,n.jsx)("div",{className:"wpb-ac__user-tags",children:a.map(e=>(0,n.jsxs)("span",{className:"wpb-ac__user-tag",children:[e.display_name," (",e.login,")",(0,n.jsx)("button",{type:"button",className:"wpb-ac__tag-remove",onClick:()=>t(e.id),"aria-label":`Remove ${e.display_name}`,children:"×"})]},e.id))})]})}const d=function({namespace:e,resourceKey:a,restApiRoot:l,nonce:i,title:d="Access Control",description:p="Control which users are allowed to connect to this MCP server. Administrators always have access regardless of this setting.",saveLabel:u="Save Access Control",onSave:h,hideSaveButton:m=!1,hideHeader:b=!1,onChange:w}){const[_,v]=(0,s.useState)(!0),[g,y]=(0,s.useState)(!1),[j,x]=(0,s.useState)(null),[f,N]=(0,s.useState)([]),[C,S]=(0,s.useState)(""),[k,$]=(0,s.useState)([]),[A,R]=(0,s.useState)([]),E=e.split("/").map(encodeURIComponent).join("%2F"),U=(0,s.useCallback)(e=>{const s=e.map(e=>`include[]=${e}`).join("&");c()({url:`${l}/wp/v2/users?${s}&per_page=100`}).then(e=>{R(e.map(e=>({id:String(e.id),login:e.slug,display_name:e.name})))}).catch(()=>{R(e.map(e=>({id:e,login:e,display_name:e})))})},[l]);(0,s.useEffect)(()=>{if(!a)return void v(!1);v(!0),x(null),N([]),S(""),$([]),R([]);let e=!1;return Promise.all([c()({url:`${l}/wpb-ac/v1/providers`}),c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`})]).then(([s,a])=>{if(e)return;N(s);const c=a.key||"",n=a.value||[];S(c),$(n),"wp_user"===c&&n.length>0&&U(n)}).catch(()=>{}).finally(()=>{e||v(!1)}),()=>{e=!0}},[l,E,a]);const L=(0,s.useCallback)(e=>{S(e),$([]),R([]),x(null)},[]),P=(0,s.useCallback)(e=>{$(s=>s.includes(e)?s.filter(s=>s!==e):[...s,e])},[]),T=(0,s.useCallback)(e=>{R(s=>s.find(s=>s.id===e.id)?s:[...s,e]),$(s=>s.includes(e.id)?s:[...s,e.id])},[]),F=(0,s.useCallback)(e=>{R(s=>s.filter(s=>s.id!==e)),$(s=>s.filter(s=>s!==e))},[]),I=(0,s.useCallback)(async()=>{y(!0),x(null);try{""===C?await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"DELETE"}):await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"PUT",data:{ac_key:C,ac_options:k}}),x({type:"success",message:"Access control saved."}),h?.(C,k)}catch(e){x({type:"error",message:e?.message||"Failed to save."})}finally{y(!1)}},[C,k,E,a,l,h]);(0,s.useEffect)(()=>{_||w?.(C,k)},[C,k,_]);const M=f.find(e=>e.id===C)||null,O=M&&"wp_user"!==M.id&&M.options?.length>0,B="wp_user"===C;return _?(0,n.jsx)("div",{className:"wpb-ac wpb-ac--loading",children:"Loading…"}):(0,n.jsxs)("div",{className:"wpb-ac",children:[!b&&(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("h2",{className:"wpb-ac__title",children:d}),(0,n.jsx)("p",{className:"wpb-ac__description",children:p})]}),(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:"Who can access"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(t,{providers:f,value:C,onChange:L})})]}),O&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:M.label}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(r,{providerId:C,options:M.options,selectedOptions:k,onToggle:P})})]}),B&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:M?.label||"Users"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(o,{restApiRoot:l,selectedUsers:A,onAdd:T,onRemove:F})})]}),j&&(0,n.jsx)("p",{className:`wpb-ac__notice wpb-ac__notice--${j.type}`,children:j.message}),!m&&(0,n.jsx)("div",{className:"wpb-ac__footer",children:(0,n.jsx)("button",{type:"button",className:"wpb-ac__save-btn",onClick:I,disabled:g,children:g?"Saving…":u})})]})};if("undefined"!=typeof window){const e=document.getElementById("wpb-access-control");if(e){const a=window.wpbAcConfig||{};a.nonce&&c().use(c().createNonceMiddleware(a.nonce)),(0,s.render)((0,n.jsx)(d,{namespace:a.namespace||"",resourceKey:a.resourceKey||"",restApiRoot:a.restApiRoot||"/wp-json",nonce:a.nonce||"",title:a.title,description:a.description,saveLabel:a.saveLabel}),e)}}})(); \ No newline at end of file +(()=>{"use strict";var e={n:s=>{var a=s&&s.__esModule?()=>s.default:()=>s;return e.d(a,{a}),a},d:(s,a)=>{for(var c in a)e.o(a,c)&&!e.o(s,c)&&Object.defineProperty(s,c,{enumerable:!0,get:a[c]})},o:(e,s)=>Object.prototype.hasOwnProperty.call(e,s)};const s=window.wp.element,a=window.wp.apiFetch;var c=e.n(a);const l=window.ReactJSXRuntime,n=[{value:"",label:"No user access added by admin"},{value:"everyone",label:"Everyone (no restriction)"}];function t({providers:e,value:s,onChange:a}){const c=e.filter(e=>!1!==e.available);return(0,l.jsxs)("select",{className:"wpb-ac__select",value:s,onChange:e=>a(e.target.value),children:[n.map(e=>(0,l.jsx)("option",{value:e.value,children:e.label},e.value)),c.map(e=>(0,l.jsx)("option",{value:e.id,children:e.label},e.id))]})}const i={wp_role:"Select which WordPress Role values may access this resource. Leave all unchecked to deny everyone (except administrators).",wp_capability:"Select which WordPress capabilities grant access. Users holding any of the checked capabilities are allowed. Administrators always have access.",bb_profile_type:"Select which BuddyBoss profile types grant access. Users assigned to any of the checked profile types are allowed. Administrators always have access.",mepr_membership:"Select which MemberPress memberships grant access. Users with an active subscription to any of the checked memberships are allowed. Administrators always have access."};function r({providerId:e,options:s,selectedOptions:a,onToggle:c}){const n=i[e]||null;return(0,l.jsxs)("div",{className:"wpb-ac__options-panel",children:[n&&(0,l.jsx)("p",{className:"wpb-ac__panel-description",children:n}),(0,l.jsx)("ul",{className:"wpb-ac__checkbox-list",children:s.map(e=>(0,l.jsx)("li",{className:"wpb-ac__checkbox-item",children:(0,l.jsxs)("label",{children:[(0,l.jsx)("input",{type:"checkbox",value:e.id,checked:a.includes(e.id),onChange:()=>c(e.id)}),e.label]})},e.id))})]})}function o({pluginSlug:e,restApiRoot:a,selectedUsers:n,onAdd:t,onRemove:i}){const[r,o]=(0,s.useState)(""),[d,p]=(0,s.useState)([]),[u,h]=(0,s.useState)(!1),m=(0,s.useRef)(null),b=(0,s.useRef)(null);(0,s.useEffect)(()=>{const e=e=>{b.current&&!b.current.contains(e.target)&&h(!1)};return document.addEventListener("mousedown",e),()=>document.removeEventListener("mousedown",e)},[]);const w=(0,s.useCallback)(s=>{if(!s.trim())return p([]),void h(!1);c()({url:`${a}/wpb-ac/v1/${e}/users?search=${encodeURIComponent(s)}`}).then(e=>{p(e||[]),h(!0)}).catch(()=>p([]))},[e,a]),_=(0,s.useCallback)(e=>{const s=e.target.value;o(s),clearTimeout(m.current),m.current=setTimeout(()=>w(s),300)},[w]),v=(0,s.useCallback)(e=>{t({id:e.id,login:e.login,display_name:e.display_name}),o(""),p([]),h(!1)},[t]),g=d.filter(e=>!n.find(s=>s.id===e.id));return(0,l.jsxs)("div",{className:"wpb-ac__user-panel",ref:b,children:[(0,l.jsx)("p",{className:"wpb-ac__panel-description",children:"Search by username or email and select one or more users. Administrators always have access regardless of this list."}),(0,l.jsxs)("div",{className:"wpb-ac__search-wrapper",children:[(0,l.jsx)("input",{type:"text",className:"wpb-ac__search-input",placeholder:"Search by username or email...",value:r,onChange:_,onFocus:()=>g.length&&h(!0)}),u&&g.length>0&&(0,l.jsx)("ul",{className:"wpb-ac__search-dropdown",children:g.map(e=>(0,l.jsxs)("li",{className:"wpb-ac__search-option",onMouseDown:()=>v(e),children:[e.display_name," (",e.login,")"]},e.id))})]}),n.length>0&&(0,l.jsx)("div",{className:"wpb-ac__user-tags",children:n.map(e=>(0,l.jsxs)("span",{className:"wpb-ac__user-tag",children:[e.display_name," (",e.login,")",(0,l.jsx)("button",{type:"button",className:"wpb-ac__tag-remove",onClick:()=>i(e.id),"aria-label":`Remove ${e.display_name}`,children:"×"})]},e.id))})]})}const d=function({pluginSlug:e,namespace:a,resourceKey:n,restApiRoot:i,nonce:d,title:p="Access Control",description:u="Control which users are allowed to connect to this MCP server. Administrators always have access regardless of this setting.",saveLabel:h="Save Access Control",onSave:m,hideSaveButton:b=!1,hideHeader:w=!1,onChange:_}){const[v,g]=(0,s.useState)(!0),[y,f]=(0,s.useState)(!1),[j,x]=(0,s.useState)(null),[N,S]=(0,s.useState)([]),[C,$]=(0,s.useState)(""),[k,A]=(0,s.useState)([]),[R,P]=(0,s.useState)([]),E=a.split("/").map(encodeURIComponent).join("%2F"),U=(0,s.useCallback)(e=>{const s=e.map(e=>`include[]=${e}`).join("&");c()({url:`${i}/wp/v2/users?${s}&per_page=100`}).then(e=>{P(e.map(e=>({id:String(e.id),login:e.slug,display_name:e.name})))}).catch(()=>{P(e.map(e=>({id:e,login:e,display_name:e})))})},[i]);(0,s.useEffect)(()=>{if(!n)return void g(!1);g(!0),x(null),S([]),$(""),A([]),P([]);let s=!1;return Promise.all([c()({url:`${i}/wpb-ac/v1/${e}/providers`}),c()({url:`${i}/wpb-ac/v1/${e}/rules/${E}/${n}`})]).then(([e,a])=>{if(s)return;S(e);const c=a.key||"",l=a.value||[];$(c),A(l),"wp_user"===c&&l.length>0&&U(l)}).catch(()=>{}).finally(()=>{s||g(!1)}),()=>{s=!0}},[e,i,E,n]);const L=(0,s.useCallback)(e=>{$(e),A([]),P([]),x(null)},[]),M=(0,s.useCallback)(e=>{A(s=>s.includes(e)?s.filter(s=>s!==e):[...s,e])},[]),T=(0,s.useCallback)(e=>{P(s=>s.find(s=>s.id===e.id)?s:[...s,e]),A(s=>s.includes(e.id)?s:[...s,e.id])},[]),F=(0,s.useCallback)(e=>{P(s=>s.filter(s=>s.id!==e)),A(s=>s.filter(s=>s!==e))},[]),I=(0,s.useCallback)(async()=>{f(!0),x(null);try{""===C?await c()({url:`${i}/wpb-ac/v1/${e}/rules/${E}/${n}`,method:"DELETE"}):await c()({url:`${i}/wpb-ac/v1/${e}/rules/${E}/${n}`,method:"PUT",data:{ac_key:C,ac_options:k}}),x({type:"success",message:"Access control saved."}),m?.(C,k)}catch(e){x({type:"error",message:e?.message||"Failed to save."})}finally{f(!1)}},[C,k,e,E,n,i,m]);(0,s.useEffect)(()=>{v||_?.(C,k)},[C,k,v]);const O=N.find(e=>e.id===C)||null,B=O&&"wp_user"!==O.id&&O.options?.length>0,K="wp_user"===C;return v?(0,l.jsx)("div",{className:"wpb-ac wpb-ac--loading",children:"Loading…"}):(0,l.jsxs)("div",{className:"wpb-ac",children:[!w&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)("h2",{className:"wpb-ac__title",children:p}),(0,l.jsx)("p",{className:"wpb-ac__description",children:u})]}),(0,l.jsxs)("div",{className:"wpb-ac__row",children:[(0,l.jsx)("div",{className:"wpb-ac__label",children:"Who can access"}),(0,l.jsx)("div",{className:"wpb-ac__control",children:(0,l.jsx)(t,{providers:N,value:C,onChange:L})})]}),B&&(0,l.jsxs)("div",{className:"wpb-ac__row",children:[(0,l.jsx)("div",{className:"wpb-ac__label",children:O.label}),(0,l.jsx)("div",{className:"wpb-ac__control",children:(0,l.jsx)(r,{providerId:C,options:O.options,selectedOptions:k,onToggle:M})})]}),K&&(0,l.jsxs)("div",{className:"wpb-ac__row",children:[(0,l.jsx)("div",{className:"wpb-ac__label",children:O?.label||"Users"}),(0,l.jsx)("div",{className:"wpb-ac__control",children:(0,l.jsx)(o,{pluginSlug:e,restApiRoot:i,selectedUsers:R,onAdd:T,onRemove:F})})]}),j&&(0,l.jsx)("p",{className:`wpb-ac__notice wpb-ac__notice--${j.type}`,children:j.message}),!b&&(0,l.jsx)("div",{className:"wpb-ac__footer",children:(0,l.jsx)("button",{type:"button",className:"wpb-ac__save-btn",onClick:I,disabled:y,children:y?"Saving…":h})})]})};if("undefined"!=typeof window){const e=document.getElementById("wpb-access-control");if(e){const a=window.wpbAcConfig||{};a.pluginSlug?(a.nonce&&c().use(c().createNonceMiddleware(a.nonce)),(0,s.render)((0,l.jsx)(d,{pluginSlug:a.pluginSlug,namespace:a.namespace||"",resourceKey:a.resourceKey||"",restApiRoot:a.restApiRoot||"/wp-json",nonce:a.nonce||"",title:a.title,description:a.description,saveLabel:a.saveLabel}),e)):console.error("wpb-access-control: window.wpbAcConfig.pluginSlug is required. Pass the same slug you used in the PHP AccessControlManager constructor.")}}})(); \ No newline at end of file diff --git a/js/AccessControl.js b/js/AccessControl.js index 7023610..fbe8c7e 100644 --- a/js/AccessControl.js +++ b/js/AccessControl.js @@ -6,7 +6,9 @@ * * Required props * -------------- - * @param {string} namespace Access-control namespace, e.g. "mcp". + * @param {string} pluginSlug Consumer slug (matches the PHP table_slug). + * Every REST URL is prefixed with /{pluginSlug}/. + * @param {string} namespace Resource namespace, e.g. "procureco/v1". * @param {string} resourceKey Resource key, e.g. "server". * @param {string} restApiRoot WP REST API root URL, e.g. "https://site.com/wp-json". * @param {string} nonce wp_create_nonce('wp_rest') value. @@ -46,6 +48,7 @@ import UserSearchPanel from './components/UserSearchPanel'; const NO_ACCESS = ''; export function AccessControl( { + pluginSlug, namespace, resourceKey, restApiRoot, @@ -119,9 +122,9 @@ export function AccessControl( { let cancelled = false; Promise.all( [ - apiFetch( { url: `${ restApiRoot }/wpb-ac/v1/providers` } ), + apiFetch( { url: `${ restApiRoot }/wpb-ac/v1/${ pluginSlug }/providers` } ), apiFetch( { - url: `${ restApiRoot }/wpb-ac/v1/rules/${ encodedNs }/${ resourceKey }`, + url: `${ restApiRoot }/wpb-ac/v1/${ pluginSlug }/rules/${ encodedNs }/${ resourceKey }`, } ), ] ) .then( ( [ provs, rule ] ) => { @@ -145,7 +148,7 @@ export function AccessControl( { return () => { cancelled = true; }; - }, [ restApiRoot, encodedNs, resourceKey ] ); // eslint-disable-line react-hooks/exhaustive-deps + }, [ pluginSlug, restApiRoot, encodedNs, resourceKey ] ); // eslint-disable-line react-hooks/exhaustive-deps const handleProviderChange = useCallback( ( newKey ) => { setSelectedKey( newKey ); @@ -183,12 +186,12 @@ export function AccessControl( { try { if ( selectedKey === NO_ACCESS ) { await apiFetch( { - url: `${ restApiRoot }/wpb-ac/v1/rules/${ encodedNs }/${ resourceKey }`, + url: `${ restApiRoot }/wpb-ac/v1/${ pluginSlug }/rules/${ encodedNs }/${ resourceKey }`, method: 'DELETE', } ); } else { await apiFetch( { - url: `${ restApiRoot }/wpb-ac/v1/rules/${ encodedNs }/${ resourceKey }`, + url: `${ restApiRoot }/wpb-ac/v1/${ pluginSlug }/rules/${ encodedNs }/${ resourceKey }`, method: 'PUT', data: { ac_key: selectedKey, ac_options: selectedOptions }, } ); @@ -204,7 +207,7 @@ export function AccessControl( { } finally { setIsSaving( false ); } - }, [ selectedKey, selectedOptions, encodedNs, resourceKey, restApiRoot, onSave ] ); + }, [ selectedKey, selectedOptions, pluginSlug, encodedNs, resourceKey, restApiRoot, onSave ] ); // Notify the parent whenever the selection changes and loading is complete. // Fires on initial data load (isLoading false) and on every user interaction. @@ -270,6 +273,7 @@ export function AccessControl( {
{ setResults( data || [] ); @@ -55,7 +57,7 @@ export default function UserSearchPanel( { } ) .catch( () => setResults( [] ) ); }, - [ restApiRoot ] + [ pluginSlug, restApiRoot ] ); const handleSearchChange = useCallback( diff --git a/js/index.js b/js/index.js index 03a6bd8..92f4bca 100644 --- a/js/index.js +++ b/js/index.js @@ -15,6 +15,7 @@ * `window.wpbAcConfig`: * * wp_localize_script( 'wpb-access-control', 'wpbAcConfig', [ + * 'pluginSlug' => 'my_plugin', // required — matches the PHP table_slug * 'namespace' => 'mcp', * 'resourceKey' => 'server', * 'restApiRoot' => get_rest_url(), @@ -43,21 +44,30 @@ if ( typeof window !== 'undefined' ) { if ( root ) { const config = window.wpbAcConfig || {}; - if ( config.nonce ) { - apiFetch.use( apiFetch.createNonceMiddleware( config.nonce ) ); - } + if ( ! config.pluginSlug ) { + // eslint-disable-next-line no-console + console.error( + 'wpb-access-control: window.wpbAcConfig.pluginSlug is required. ' + + 'Pass the same slug you used in the PHP AccessControlManager constructor.' + ); + } else { + if ( config.nonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( config.nonce ) ); + } - render( - , - root - ); + render( + , + root + ); + } } } diff --git a/src/AccessControlManager.php b/src/AccessControlManager.php index 4d81535..cdbd608 100644 --- a/src/AccessControlManager.php +++ b/src/AccessControlManager.php @@ -7,7 +7,10 @@ * * Usage * ----- - * $manager = new AccessControlManager( 'my_plugin_access_control_providers' ); + * $manager = new AccessControlManager( + * 'my_plugin_access_control_providers', // filter tag for provider registration + * 'my_plugin' // table slug — required, see Slug::PATTERN + * ); * * if ( ! $manager->user_has_access( get_current_user_id(), 'my-namespace', 'my-resource' ) ) { * wp_die( 'Access denied.', 403 ); @@ -40,6 +43,7 @@ use WPBoilerplate\AccessControl\Database\Rule\RuleQuery; use WPBoilerplate\AccessControl\RestApi\RulesController; +use WPBoilerplate\AccessControl\Slug; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -61,6 +65,16 @@ class AccessControlManager { */ private $providers_filter; + /** + * Consumer-supplied slug. Determines the table name, cache group, and + * REST route prefix for this manager instance. See {@see Slug::PATTERN}. + * + * @since 2.0.0 + * + * @var string + */ + private $table_slug; + /** * Registered provider instances, keyed by provider ID. * @@ -77,12 +91,19 @@ class AccessControlManager { /** * @since 1.0.0 + * @since 2.0.0 `$table_slug` parameter added and made required so each + * consumer plugin owns its own table, cache group, and REST + * route prefix. * * @param string $providers_filter WordPress filter tag for provider registration. + * @param string $table_slug Per-consumer slug. See {@see Slug::PATTERN}. + * + * @throws \InvalidArgumentException When the slug fails validation. */ - public function __construct( string $providers_filter = 'wpb_access_control_providers' ) { + public function __construct( string $providers_filter, string $table_slug ) { $this->providers_filter = $providers_filter; - $this->query = new RuleQuery(); + $this->table_slug = Slug::sanitize( $table_slug ); + $this->query = new RuleQuery( $this->table_slug ); if ( did_action( 'init' ) ) { $this->load_providers(); @@ -91,6 +112,18 @@ public function __construct( string $providers_filter = 'wpb_access_control_prov } } + /** + * Return the slug this manager (and its table / cache group / REST + * routes) is bound to. + * + * @since 2.0.0 + * + * @return string + */ + public function get_table_slug(): string { + return $this->table_slug; + } + // ------------------------------------------------------------------------- // Provider registry // ------------------------------------------------------------------------- @@ -179,7 +212,7 @@ public function get_query(): RuleQuery { * @return void */ public function register_rest_api(): void { - ( new RulesController( $this ) )->register_routes(); + ( new RulesController( $this, $this->table_slug ) )->register_routes(); } // ------------------------------------------------------------------------- diff --git a/src/Database/Rule/RuleQuery.php b/src/Database/Rule/RuleQuery.php index b09eef8..7ddc66e 100644 --- a/src/Database/Rule/RuleQuery.php +++ b/src/Database/Rule/RuleQuery.php @@ -24,6 +24,7 @@ namespace WPBoilerplate\AccessControl\Database\Rule; use BerlinDB\Database\Kern\Query; +use WPBoilerplate\AccessControl\Slug; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -32,7 +33,12 @@ /** * Access-control rule query. * + * Each instance is bound to a single consumer slug — the physical table, + * object-cache group, and transient prefix are all derived from it so two + * plugins embedding the library never collide. + * * @since 1.0.0 + * @since 2.0.0 Constructor accepts a required `$table_slug` argument. */ class RuleQuery extends Query { @@ -40,8 +46,15 @@ class RuleQuery extends Query { // BerlinDB Query configuration // ------------------------------------------------------------------------- - /** @var string Table name without $wpdb->prefix. */ - protected $table_name = 'wpb_access_control'; + /** + * Table name without `$wpdb->prefix`. + * + * Set per-instance in the constructor from the slug; never shared + * across consumers. + * + * @var string + */ + protected $table_name = ''; /** @var string Short alias used in SQL JOINs. */ protected $table_alias = 'wpac'; @@ -60,11 +73,13 @@ class RuleQuery extends Query { /** * Object-cache group. + * + * Set per-instance in the constructor from the slug — `wpb_ac_{slug}`. * Must not contain colons or spaces (BerlinDB restriction). * * @var string */ - protected $cache_group = 'wpb_access_control'; + protected $cache_group = ''; /** * Transient TTL in seconds (7 days). @@ -76,26 +91,51 @@ class RuleQuery extends Query { const TRANSIENT_TTL = 604800; /** - * Static guard: ensures RuleTable is instantiated exactly once per request. - * BerlinDB's Table registers $wpdb->wpb_access_control, which Query reads - * via get_table_name() — so Table must exist before any Query is used. + * Slug this Query instance is bound to. Used to scope the transient key + * prefix so two consumers do not share cached payloads. + * + * @since 2.0.0 * - * @var bool + * @var string */ - private static $table_setup = false; + private $table_slug = ''; + + /** + * Per-slug registry of already-instantiated RuleTables. + * + * BerlinDB's Table registers `$wpdb->{name}`, which Query reads via + * `get_table_name()`. We need exactly one Table per slug per request; + * additional instantiations are no-ops at the BerlinDB layer but waste + * cycles, so we guard with this registry. + * + * @since 2.0.0 + * + * @var array + */ + private static $tables_setup_by_slug = array(); // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- /** - * @since 1.0.0 + * @since 2.0.0 + * + * @param string $table_slug Consumer-supplied slug. See {@see Slug::PATTERN}. + * + * @throws \InvalidArgumentException When the slug fails validation. */ - public function __construct() { - if ( ! self::$table_setup ) { - self::$table_setup = true; - new RuleTable(); + public function __construct( string $table_slug ) { + $slug = Slug::sanitize( $table_slug ); + $this->table_slug = $slug; + $this->table_name = $slug . '_access_control'; + $this->cache_group = 'wpb_ac_' . $slug; + + if ( ! isset( self::$tables_setup_by_slug[ $slug ] ) ) { + self::$tables_setup_by_slug[ $slug ] = true; + new RuleTable( $slug ); } + parent::__construct(); } @@ -316,10 +356,12 @@ private function purge_resource( string $namespace, string $key ): void { /** * Build a deterministic WordPress transient key for a (namespace, key) pair. * - * MD5 keeps the name well within WordPress's 172-character transient limit - * regardless of how long the namespace or key is. + * The slug is included in the MD5 input so two consumers can never collide + * on a cached payload. The `wpbac_` prefix keeps every key well within + * WordPress's 172-character transient limit. * * @since 1.0.0 + * @since 2.0.0 The slug is folded into the hash for per-consumer isolation. * * @param string $namespace Resource namespace. * @param string $key Resource key. @@ -327,7 +369,7 @@ private function purge_resource( string $namespace, string $key ): void { * @return string Transient key, e.g. "wpbac_a1b2c3…". */ private function transient_key( string $namespace, string $key ): string { - return 'wpbac_' . md5( $namespace . '|' . $key ); + return 'wpbac_' . md5( $this->table_slug . '|' . $namespace . '|' . $key ); } /** diff --git a/src/Database/Rule/RuleTable.php b/src/Database/Rule/RuleTable.php index 28c205a..596a433 100644 --- a/src/Database/Rule/RuleTable.php +++ b/src/Database/Rule/RuleTable.php @@ -2,23 +2,30 @@ /** * Rule database table definition for BerlinDB. * - * Defines the {prefix}wpb_access_control schema. RuleQuery instantiates this - * automatically on first use — consuming plugins do not need to manage it. + * Defines the schema for one consumer plugin's access-control table. The + * physical table name is derived from the slug passed to the constructor: + * `{wp_prefix}{slug}_access_control`. + * + * `RuleQuery` instantiates this automatically on first use per slug — + * consuming plugins do not need to manage it. * * @package WPBoilerplate\AccessControl\Database\Rule * @since 1.0.0 + * @since 2.0.0 Constructor accepts a required `$table_slug` argument so each + * consumer gets its own table. */ namespace WPBoilerplate\AccessControl\Database\Rule; use BerlinDB\Database\Kern\Table; +use WPBoilerplate\AccessControl\Slug; if ( ! defined( 'ABSPATH' ) ) { exit; } /** - * Owns the {prefix}wpb_access_control table schema and BerlinDB upgrades. + * Owns the `{prefix}{slug}_access_control` table schema and BerlinDB upgrades. * * One flat row per option value — no JSON storage. See AGENTS.md for the * full schema and rule storage convention. @@ -40,8 +47,15 @@ class RuleTable extends Table { // BerlinDB Table properties // ------------------------------------------------------------------------- - /** @var string Table name without the global $wpdb->prefix. */ - protected $name = 'wpb_access_control'; + /** + * Table name without the global `$wpdb->prefix`. + * + * Set per-instance in the constructor from the slug — left empty by + * default so BerlinDB never sees a shared name. + * + * @var string + */ + protected $name = ''; /** * Schema class for BerlinDB to instantiate. @@ -62,9 +76,12 @@ class RuleTable extends Table { /** * WordPress option key used to store the installed schema version. * + * Set per-instance in the constructor from the slug — `wpb_ac_{slug}_db_version` + * — so consumer plugins do not overwrite each other's version pointers. + * * @var string */ - protected $db_version_key = 'wpb_access_control_db_version'; + protected $db_version_key = ''; /** * Version-to-method map for BerlinDB's upgrade runner. @@ -76,6 +93,25 @@ class RuleTable extends Table { 202605120001 => 'upgrade_202605120001', ); + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * @since 2.0.0 + * + * @param string $table_slug Consumer-supplied slug. See {@see Slug::PATTERN}. + * + * @throws \InvalidArgumentException When the slug fails validation. + */ + public function __construct( string $table_slug ) { + $slug = Slug::sanitize( $table_slug ); + $this->name = $slug . '_access_control'; + $this->db_version_key = 'wpb_ac_' . $slug . '_db_version'; + + parent::__construct(); + } + // ------------------------------------------------------------------------- // Upgrade methods // ------------------------------------------------------------------------- @@ -88,8 +124,8 @@ class RuleTable extends Table { * intentionally discarded — resources default to "no restriction" until * an admin reconfigures them. * - * This runs exactly once (when stored db_version < 202605120001). Future - * schema changes must NOT drop the table. + * This runs exactly once per slug (when the stored db_version < + * 202605120001). Future schema changes must NOT drop the table. * * @since 1.0.0 * diff --git a/src/RestApi/RulesController.php b/src/RestApi/RulesController.php index c9181a5..44c7617 100644 --- a/src/RestApi/RulesController.php +++ b/src/RestApi/RulesController.php @@ -69,13 +69,29 @@ class RulesController extends WP_REST_Controller { /** @var AccessControlManager */ private $manager; + /** + * Consumer slug — every registered route is prefixed with `/{slug}/...` + * so two consumer plugins do not collide on the same REST route. + * + * @since 2.0.0 + * + * @var string + */ + private $table_slug; + /** * @since 1.0.0 + * @since 2.0.0 `$table_slug` parameter added — every route path is + * prefixed with this slug so consumers do not collide. * - * @param AccessControlManager $manager Provider registry instance. + * @param AccessControlManager $manager Provider registry instance. + * @param string $table_slug Slug to scope routes under. + * Caller (`AccessControlManager`) + * already validates it. */ - public function __construct( AccessControlManager $manager ) { - $this->manager = $manager; + public function __construct( AccessControlManager $manager, string $table_slug = '' ) { + $this->manager = $manager; + $this->table_slug = $table_slug; } /** @@ -91,10 +107,12 @@ public function __construct( AccessControlManager $manager ) { */ public function register_routes(): void { - // GET / PUT / DELETE /rules/{namespace}/{key} + $slug = '/' . $this->table_slug; + + // GET / PUT / DELETE /{slug}/rules/{namespace}/{key} register_rest_route( $this->namespace, - '/rules/(?P[^/]+)/(?P.+)', + $slug . '/rules/(?P[^/]+)/(?P.+)', array( array( 'methods' => \WP_REST_Server::READABLE, @@ -133,10 +151,10 @@ public function register_routes(): void { ) ); - // DELETE /namespaces/{namespace} + // DELETE /{slug}/namespaces/{namespace} register_rest_route( $this->namespace, - '/namespaces/(?P[^/]+)', + $slug . '/namespaces/(?P[^/]+)', array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'purge_namespace' ), @@ -153,10 +171,10 @@ public function register_routes(): void { ) ); - // GET /providers + // GET /{slug}/providers register_rest_route( $this->namespace, - '/providers', + $slug . '/providers', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_providers' ), @@ -164,10 +182,10 @@ public function register_routes(): void { ) ); - // GET /users?search=...&limit=10 + // GET /{slug}/users?search=...&limit=10 register_rest_route( $this->namespace, - '/users', + $slug . '/users', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'search_users' ), diff --git a/src/Slug.php b/src/Slug.php new file mode 100644 index 0000000..1c9357b --- /dev/null +++ b/src/Slug.php @@ -0,0 +1,63 @@ +getProperty( $name ); + $prop->setAccessible( true ); + return $prop->getValue( $query ); + } + + /** + * Build a RuleQuery whose own constructor has run (so slug-derived + * properties are populated) but whose parent BerlinDB Query + * constructor has not (so we don't need $wpdb). + */ + private function make_query_skipping_parent( string $slug ): RuleQuery { + $ref = new \ReflectionClass( RuleQuery::class ); + $instance = $ref->newInstanceWithoutConstructor(); + + // Invoke the slug-handling portion by hand. Mirrors the body of + // RuleQuery::__construct() up to the parent::__construct() call. + $slug_prop = $ref->getProperty( 'table_slug' ); + $slug_prop->setAccessible( true ); + $slug_prop->setValue( $instance, $slug ); + + $name_prop = $ref->getProperty( 'table_name' ); + $name_prop->setAccessible( true ); + $name_prop->setValue( $instance, $slug . '_access_control' ); + + $cg_prop = $ref->getProperty( 'cache_group' ); + $cg_prop->setAccessible( true ); + $cg_prop->setValue( $instance, 'wpb_ac_' . $slug ); + + return $instance; + } + + // ------------------------------------------------------------------------- + // Slug → derived names + // ------------------------------------------------------------------------- + + public function test_table_name_is_derived_from_slug(): void { + $query = $this->make_query_skipping_parent( 'mcp' ); + $this->assertSame( 'mcp_access_control', $this->read_property( $query, 'table_name' ) ); + } + + public function test_cache_group_is_derived_from_slug(): void { + $query = $this->make_query_skipping_parent( 'mcp' ); + $this->assertSame( 'wpb_ac_mcp', $this->read_property( $query, 'cache_group' ) ); + } + + public function test_two_slugs_produce_independent_table_names(): void { + $a = $this->make_query_skipping_parent( 'plugin_a' ); + $b = $this->make_query_skipping_parent( 'plugin_b' ); + + $this->assertSame( 'plugin_a_access_control', $this->read_property( $a, 'table_name' ) ); + $this->assertSame( 'plugin_b_access_control', $this->read_property( $b, 'table_name' ) ); + $this->assertNotSame( + $this->read_property( $a, 'table_name' ), + $this->read_property( $b, 'table_name' ) + ); + } + + public function test_two_slugs_produce_independent_cache_groups(): void { + $a = $this->make_query_skipping_parent( 'plugin_a' ); + $b = $this->make_query_skipping_parent( 'plugin_b' ); + + $this->assertNotSame( + $this->read_property( $a, 'cache_group' ), + $this->read_property( $b, 'cache_group' ) + ); + } + + // ------------------------------------------------------------------------- + // Slug validation propagation + // ------------------------------------------------------------------------- + + public function test_constructor_rejects_empty_slug(): void { + $this->expectException( \InvalidArgumentException::class ); + new RuleQuery( '' ); + } + + public function test_constructor_rejects_slug_with_uppercase(): void { + $this->expectException( \InvalidArgumentException::class ); + new RuleQuery( 'MyPlugin' ); + } + + public function test_constructor_rejects_slug_with_sql_injection_chars(): void { + $this->expectException( \InvalidArgumentException::class ); + new RuleQuery( "wpb';--" ); + } +} diff --git a/tests/Unit/RestApi/RulesControllerTest.php b/tests/Unit/RestApi/RulesControllerTest.php index 7d9d536..4888526 100644 --- a/tests/Unit/RestApi/RulesControllerTest.php +++ b/tests/Unit/RestApi/RulesControllerTest.php @@ -369,5 +369,48 @@ public function test_validate_key_rejects_empty_and_too_long_values(): void { $this->assertInstanceOf( WP_Error::class, $controller->validate_key( str_repeat( 'a', RuleTable::KEY_LENGTH + 1 ) ) ); $this->assertTrue( $controller->validate_key( str_repeat( 'a', RuleTable::KEY_LENGTH ) ) ); } + + // ------------------------------------------------------------------------- + // register_routes — slug-scoped route paths (v2.0.0) + // ------------------------------------------------------------------------- + + public function test_register_routes_prefixes_every_route_with_slug(): void { + $registered_paths = array(); + Functions\when( 'register_rest_route' )->alias( + static function ( $namespace, $route ) use ( &$registered_paths ): void { + $registered_paths[] = $namespace . $route; + } + ); + + $controller = new RulesController( $this->manager_mock(), 'mcp' ); + $controller->register_routes(); + + $this->assertContains( + 'wpb-ac/v1/mcp/rules/(?P[^/]+)/(?P.+)', + $registered_paths + ); + $this->assertContains( + 'wpb-ac/v1/mcp/namespaces/(?P[^/]+)', + $registered_paths + ); + $this->assertContains( 'wpb-ac/v1/mcp/providers', $registered_paths ); + $this->assertContains( 'wpb-ac/v1/mcp/users', $registered_paths ); + } + + public function test_register_routes_uses_a_distinct_prefix_per_slug(): void { + $registered_paths = array(); + Functions\when( 'register_rest_route' )->alias( + static function ( $namespace, $route ) use ( &$registered_paths ): void { + $registered_paths[] = $namespace . $route; + } + ); + + ( new RulesController( $this->manager_mock(), 'plugin_a' ) )->register_routes(); + ( new RulesController( $this->manager_mock(), 'plugin_b' ) )->register_routes(); + + $this->assertContains( 'wpb-ac/v1/plugin_a/providers', $registered_paths ); + $this->assertContains( 'wpb-ac/v1/plugin_b/providers', $registered_paths ); + $this->assertNotContains( 'wpb-ac/v1/providers', $registered_paths ); + } } } diff --git a/tests/Unit/SlugTest.php b/tests/Unit/SlugTest.php new file mode 100644 index 0000000..4501bf0 --- /dev/null +++ b/tests/Unit/SlugTest.php @@ -0,0 +1,88 @@ +assertSame( $slug, Slug::sanitize( $slug ) ); + } + + public function valid_slugs(): array { + return array( + 'lowercase' => array( 'mcp' ), + 'with_underscores' => array( 'my_plugin' ), + 'with_digits' => array( 'plugin_v2' ), + 'single_char' => array( 'a' ), + 'exactly_32_chars' => array( str_repeat( 'a', 32 ) ), + 'mixed_chars' => array( 'abilities_manager_v3' ), + 'leading_underscore_ok' => array( '_internal' ), + 'leading_digit_ok' => array( '2nd_plugin' ), + ); + } + + // ------------------------------------------------------------------------- + // Rejecting cases + // ------------------------------------------------------------------------- + + /** + * @dataProvider invalid_slugs + */ + public function test_sanitize_throws_for_invalid_slug( string $slug ): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessageMatches( '/table slug/i' ); + Slug::sanitize( $slug ); + } + + public function invalid_slugs(): array { + return array( + 'empty' => array( '' ), + 'has_uppercase' => array( 'Plugin' ), + 'has_dash' => array( 'my-plugin' ), + 'has_space' => array( 'my plugin' ), + 'has_dot' => array( 'my.plugin' ), + 'has_slash' => array( '../etc/passwd' ), + 'has_quote' => array( "mcp';--" ), + 'has_backtick' => array( '`mcp`' ), + 'too_long' => array( str_repeat( 'a', 33 ) ), + 'utf8_emoji' => array( '🔐' ), + 'whitespace_only' => array( ' ' ), + ); + } + + public function test_error_message_includes_pattern_for_diagnostics(): void { + try { + Slug::sanitize( 'BAD-slug' ); + $this->fail( 'Expected InvalidArgumentException was not thrown.' ); + } catch ( \InvalidArgumentException $e ) { + $this->assertStringContainsString( '[a-z0-9_]', $e->getMessage() ); + $this->assertStringContainsString( "'BAD-slug'", $e->getMessage() ); + } + } + + public function test_error_message_marks_empty_slug_explicitly(): void { + try { + Slug::sanitize( '' ); + $this->fail( 'Expected InvalidArgumentException was not thrown.' ); + } catch ( \InvalidArgumentException $e ) { + $this->assertStringContainsString( '(empty)', $e->getMessage() ); + } + } +}