Private store references #51596
Replies: 9 comments 16 replies
-
Do you want to enable plugin authors to create their own private APIs? Using the store({
actions: {
coolCommerce: {
calculateTax: internal(() => {
// Private, only accessible by in the `coolCommerce` namespace.
}),
},
},
}); BTW is anything preventing me from providing a Private APIs provided by the interactivity API in WordPress coreIf you want to restrict parts of the interactivity API for internal core use, you can do that with
The legacy experience for experimental APIs is prefixing public APIs with
Here's how you register a private action: gutenberg/packages/commands/src/store/index.js Lines 24 to 31 in c0077f7 So, in your example, it could be: export const instance = store({
actions: {
core: {
// Public
insertBlock: () => {}
},
},
});
// Private
unlock( instance ).registerPrivateActions( {
core: {
changeInternalBlockOrder: () => {}
}
} ); And then you would use it like: store(
{
actions: {
myPlugin: {
someAction: ({ actions }) => {
const invalid = actions.core.changeInternalBlockOrder(); // undefined!
const valid = unlock(actions).core.changeInternalBlockOrder(); // valid
},
},
},
},
[signCoolCommerce]
); The actual API could settle on Original lock/unlock PR. Coding Guidelines and code examples. Package docs. |
Beta Was this translation helpful? Give feedback.
-
Also cc @jsnajdr @youknowriad @noisysocks |
Beta Was this translation helpful? Give feedback.
-
Let me summarize how I understand this problem. Currently, WP private APIs allow a group of gutenberg/packages/private-apis/src/implementation.js Lines 12 to 27 in 12a9749 No other module can opt-in into this private group. Now, the proposal is to allow creating new friend groups, for example a group of Is that right? |
Beta Was this translation helpful? Give feedback.
-
Talking out loud here, but I'd like to share my latest thoughts. First, these are two goals I would like to accomplish:
I'm not totally convinced about this yet, but if using With this syntax, maybe it'd also make sense to reverse the namespaces, so we have We would also need a private context directive. It could be I'm not sure if it would be a good idea because moving from kebab-case to camelCase is not straightforward, and it would mean kebab-case namespaces would not be allowed (because there's no way to differentiate between kebab and camel case ones: If we reverse namespace and type, maybe it would make more sense to absorb it as a Finally, I've also been wondering if instead of using This would fix the leaking problem, but if we want to save the unlocking signature in the private scope, we have a circular loop if people are forced to unlock their own namespaces.
The only way to avoid that would be to add the unlocking signature ( With all these changes, the new API would be: <?php
wp_store( 'coolCommerce', array(
'state' => array(
'publicGlobalValue' => '...'
)
));
wp_private_store( 'coolCommerce', array(
'state' => array(
'privateGlobalValue' => '...'
)
))
?>
<div
data-wp-context--cool-commerce='{ "publicLocalValue": "..." }'
data-wp-private-context--cool-commerce='{ "privateLocalValue": "..." }'
>
<button
data-wp-on--click="coolCommerce.actions.somePublicAction"
>
Trigger some public action
</button>
<button
data-wp-on--click="private.coolCommerce.actions.somePrivateAction"
data-wp-unlock--cool-commerce="I know using private references from external plugins means my code will break on their next release"
>
Trigger some private action
</button>
</div> import { store, privateStore } from "@wordpress/interactivity";
store("coolCommerce", {
actions: {
somePublicAction: ({ private, coolCommerce }) => {
// It can access the public store.
coolCommerce.context.publicLocalValue;
coolCommerce.state.publicGlobalValue;
// It can access the private store only when unlocked.
private.coolCommerce.context.privateLocalValue;
private.coolCommerce.state.privateGlobalValue;
},
},
unlock: {
coolCommerce:
"I know using private references from external plugins means my code will break on their next release",
},
});
privatestore("coolCommerce", {
actions: {
somePrivateAction: ({ private, coolCommerce }) => {
// It can access the public store.
coolCommerce.context.publicLocalValue;
coolCommerce.state.publicGlobalValue;
// It can access its own private store without being unlocked.
private.coolCommerce.context.privateLocalValue;
private.coolCommerce.state.privateGlobalValue;
// It can access other private stores only when unlocked.
private.otherPlugin.actions.otherPrivateAction();
},
},
unlock: {
otherPlugin:
"I know using private references from external plugins means my code will break on their next release",
},
}); Again, I'm not sure about any of these ideas. I'm just sharing my latest thoughts to keep the conversation going, and in case they trigger better ideas from someone else. |
Beta Was this translation helpful? Give feedback.
-
I've got an idea that I think could work, based on the idea of using local variables to hide a value (as Jarda pointed out here) and an idea to count the number of times the unlocking functions are called without using the unlocking signature.
$unlock_hash = unlock_private_store( 'coolCommerce' ); The first protection mechanism is that this function can only be called once per private store namespace.
If an external plugin wants to get the hash, it can do so by passing the unlocking signature to the function, and therefore acknowledging that their plugin can break. When the unlocking signature is passed, the function doesn't throw even if it's called multiple times. $unlock_hash = unlock_private_store( 'coolCommerce', 'I know that my...' ); The second protection mechanism is that the hash needs to be present in the HTML tags where private directives are used. <?php
$unlock_hash = unlock_private_store( 'coolCommerce' );
$wrapper_attributes = get_block_wrapper_attributes();
?>
<div <?php echo $wrapper_attributes; ?>>
<button
data-wp-on--click="private.coolCommerce.actions.toggle"
data-wp-unlock="<?php echo $unlock_hash; ?>"
>
<?php _e( 'Toggle' ); ?>
</button>
</div> This prevents anyone without a valid hash from accessing the private store in their directive references. Internally, the If we check the hashes in the server, we don't need to do it again in the client. So if the
<?php
// my-plugin/unlock.php
$unlock_hash = unlock_private_store( 'coolCommerce' ); <?php
// my-plugin/block-1/render.php
require_once '../unlock.php';
// ...
?>
<div ...
<button
data-wp-on--click="private.coolCommerce.actions.toggle"
data-wp-unlock="<?php echo $unlock_hash; ?>"
>
... <?php
// my-plugin/block-2/render.php
require_once '../unlock.php';
// ...
?>
<div ...
<button
data-wp-on--click="private.coolCommerce.actions.toggle"
data-wp-unlock="<?php echo $unlock_hash; ?>"
>
... Finally, we need to unlock private stores in the client, so they can be used by other store references. For this, we can use a similar function: import { store, unlockPrivateStore } from "@wordpress/interactivity";
const unlockHash = unlockPrivateStore("coolCommerce");
privateStore("coolCommerce", {
state: {
somePrivateValue,
},
});
store(
"coolCommerce",
{
selectors: {
somePublicValue: ({ private }) => {
return process(private.coolCommerce.state.somePrivateValue);
},
},
},
[unlockHash]
); Again, the second time that the External plugins can still get a valid hash by using the unlocking signature: const unlockHash = unlockPrivateStore("coolCommerce", "I know that ...");
store(
"myExternalPlugin",
{
// I can use `private.coolCommerce` here
},
[unlockHash]
); If plugins need to use private references in more than one block, they need to create an external file and import the hash from there: // my-plugin/unlock.js
import { unlockPrivateStore } from "@wordpress/interactivity";
export default unlockPrivateStore("coolCommerce"); // my-plugin/block-1/view.js
import { store } from "@wordpress/interactivity";
import unlockHash from "../unlock.js";
store(
{
// ...
},
[unlockHash]
); // my-plugin/block-2/view.js
import { store } from "@wordpress/interactivity";
import unlockHash from "../unlock.js";
store(
{
// ...
},
[unlockHash]
); That's basically the gist of it. Of course, there are still ways to hack this system, but they need to be done deliberately:
I also don't consider this syntax final. Maybe I'll keep thinking about it for a while to see if this approach has any drawbacks, but I would also appreciate any thoughts 🙂 |
Beta Was this translation helpful? Give feedback.
-
Sharing the third-party store dependency on lazy loaded blocks discussion here because it may be related to this problem. |
Beta Was this translation helpful? Give feedback.
-
@DAreRodz has started implementing this version, and today we reviewed the whole API because there are things that are not quite there yet. PHP: Instead of throwing, remove the directives from the HTMLFirst, we noticed that WordPress should never throw in the server. That means that throwing when there is a second call to Instead of that, if there is a second call to Removing all the directives (even the legit ones) is important as an extra security measure in case no blocks from the owner plugin are present on the page. For example, imagine there are two calls to // plugins/cool-commerce/plugin.php
unlock_private_store( 'coolCommerce', array(
'cool-commerce/some-block',
)); The second one is not legit, and comes from another plugin: // plugins/other-plugin/plugin.php
unlock_private_store( 'coolCommerce', array(
'other-plugin/other-block',
)); If the plugin's JS: Use a build hash instead of an importIf the Interactivity API starts using a system of global imports, like Import Maps or Module Federation, people won't be able to use it to create a separate chunk that exports the unlock hash because anyone could be able to access it: // coolCommerce/unlock.js
import { unlockStore } from "@wordpress/interactivity";
export default unlockStore("coolCommerce"); // coolCommerce/some-block/view.js
import unlock from "coolCommerce/unlock";
store(
"coolCommerce",
{
// ...
},
{
unlock,
}
); // other-plugin/other-block/view.js
import unlock from "coolCommerce/unlock";
const { state, actions } = store("coolCommerce", null, {
unlock,
}); In this case, An alternative solution that doesn't require an external chunk could be to inject a random string that changes on each build, for example, a timestamp. Webpack: const webpack = require("webpack");
module.exports = {
// other webpack config ...
plugins: [
new webpack.DefinePlugin({
BUILD_TIMESTAMP: JSON.stringify(Date.now()),
}),
],
}; The API would become an option that accepts a string: const { state, actions } = store(
"coolCommerce",
{
/* config store... */
},
{ private: BUILD_TIMESTAMP } // This will be replaced with a timestamp string
); Other bundlers have similar define capabilities, for example, import * as esbuild from "esbuild";
await esbuild.build({
// other esbuild config...
define: {
BUILD_TIMESTAMP: JSON.stringify(Date.now()),
},
}); We could provide something like that already configured in The
Third-party plugins can copy the current timestamp, but that would change when the user updates the original plugin. They would do this instead: const { state, actions } = store("coolCommerce", null, {
private: "I know...",
}); Calling const { state, actions } = store("coolCommerce", null, {
private: "A different string", // Throws!!
}); This solution is also more performant because it doesn't require an extra blocking import. A similar thing will happen to
|
Beta Was this translation helpful? Give feedback.
-
We should also add the possibility of having a read only store, where this is allowed: const { state, actions } = store("coolcommerce"); but this is not: const { state, actions } = store("coolcommerce", {
// Add or overwrite parts of the store...
}); It should be easy to do now that we have the privacy the mechanism in place. cc: @DAreRodz |
Beta Was this translation helpful? Give feedback.
-
The server-side part of my last proposal is based on the assumption that we would know which part of the HTML belongs to which block during the processing, which is difficult because, after the block rendering, the block boundaries disappear. // plugins/cool-commerce/plugin.php
unlock_private_store( 'coolCommerce', array(
'cool-commerce/some-block',
)); So we should search for a solution that doesn't require us to know that. This basically means that we need to move that information to the HTML. Of course, as the HTML can be modified by plugins, this method will be less secure, but hopefully it will be good enough to discourage people from hacking the system in weird ways instead of just using the First, the most obvious solution would be to put the block name in the <div data-wp-interactive='{ "blockName": "cool-commerce/some-block" }'></div> The problems with this approach are:
To solve 2., I don't think there's a better solution than instructing blocks using private stores to wrap their inner blocks with another directive to stop the leakage: <div data-wp-interactive='{ "blockName": "cool-commerce/some-block" }'>
<h1>Some title</h1>
<div data-wp-lock-private-stores>
<?php echo $content; ?>
</div>
</div> Maybe one day we can automate this with the HTML API, but until that moment, devs will need to add it. To solve 1., my current idea is to add an additional function that can be used in the This would be the workflow:
People trying to hack this system will have to inspect a legitimate block, find the hash in the HTML, then modify their own block and add the hash to it. Or if their block is an inner block of a legitimate block, modify the parent block to remove the Hopefully, these are cumbersome enough to discourage people and encourage them to use Thoughts? Any other ideas? |
Beta Was this translation helpful? Give feedback.
-
I've started thinking if we should expose a system to limit the access of some parts of the store to certain namespaces to avoid compatibility issues. For example, an e-commerce site may choose to expose
actions.coolCommerce.addToCart
publicly, but notactions.coolCommerce.calculateTax
.The places where we would have to limit access are:
wp_store
in PHPstore
in JSdata-wp-directive="ref"
refs during the PHP SSRdata-wp-directive="ref"
refs on the JS hydrationIn terms of implementation:
wp_store
. We could get the namespace from the block name declared on theblock.json
. I.e.,"name": "coolCommerce/someBlock"
->coolCommerce
.data-wp-interactive
with the namespace, likedata-wp-interactive="coolCommerce"
. That would mean thatdata-wp-interactive
won't be optional when client-side navigation is activated, but I think that's fine. The namespace would stay until anotherdata-wp-interactive
with a different namespace is found, similar to howwp-context
works today.Directive
component is created using the value of the lastdata-wp-interactive
as well.The injection of
data-wp-interactive="namespace"
can be done automatically, of course.In my opinion, we should not deviate much from the current developer experience, so the syntax for private references could be something as simple as starting the property with
_
. Something like:Once this is in place, we could use a system similar to the lock/unlock mechanism to grant access to a namespace internals:
This system would only pass those private references to other private references, to avoid leaking private references to third consumers.
Signing in the HTML could be done inlining the warning, like this:
Or with a private reference:
Maybe with this last option, we can unify the signing of the directives and store into one, by making the
sign
property (or whatever name we choose) special for that purpose.There is a way to avoid signing because namespaces are not enforced: a person that wants to access
actions.coolCommerce._calculateTax
could create a block that is namedcoolCommerce/myExternalBlock
or add a filter just before the SSR and change the value ofdata-wp-interactive="coolCommerce"
. Also, they could gain access without signing if they delete theirdata-wp-interactive
attribute and they are inner blocks of an interactive block fromcoolCommerce
.If that is unacceptable, everybody would have to sign when they want to access private references, even for their own namespace. But I don't like this option because if someone is deliberately trying to avoid the signing, it means they already know what it means, and this would just make the DX worse for everybody else.
These are just my first thoughts and I haven't done any proof of concept of the hypotheses. But happy to hear other opinions 🙂
Beta Was this translation helpful? Give feedback.
All reactions