Files

112 lines
6.2 KiB
Markdown
Raw Permalink Normal View History

# Contract
Every hook this plugin offers, and every family hook it answers. Version 1.7.0.
A contract is a name somebody else has to know. Core hooks this plugin merely
listens to — `init`, `admin_menu`, `wp_enqueue_scripts` — are not here;
WordPress documents those, and listening to `init` is not a promise to anyone.
The exception is at the bottom, and it is there because it is shared with every
sibling plugin on the same update host.
The tables and the source are compared in both directions by
`ci/check-docs.php`, which runs in CI. A hook added without a row here fails
the build, and so does a row whose name no longer exists in the source.
## Hooks this plugin provides
All six are filters, all read a setting that has a working default, and all are
answered from `apply_filters()` in shipped code. A shop that adds no code gets
the default and never needs this table.
| hook | signature | default | if you are absent |
| --- | --- | --- | --- |
| `wc_edm_audit_log_enabled` | `bool $enabled` | `true` | the audit log records admin actions |
| `wc_edm_audit_log_retention_days` | `int $days` | `30` | rows older than 30 days are pruned daily; the value is floored at 1, so `0` means 1 day, not "keep forever" |
| `wc_edm_client_ip` | `string $ip` | the resolved address | the plugin's own resolution is used — override this behind a proxy |
| `wc_edm_rate_limit_enabled` | `bool $enabled` | `true` | REST routes are rate limited |
| `wc_edm_rate_limit_max_requests` | `int $default, string $method` | per method | the built-in allowance applies; floored at 1 |
| `wc_edm_rate_limit_window` | `int $seconds, string $method` | `MINUTE_IN_SECONDS` | a 60-second window; floored at 1 |
**What a consumer must not do.** Do not return a non-scalar: each of these is
cast (`(bool)`, `(int)`, `(string)`) and a bad type becomes a wrong value rather
than an error. Do not assume `wc_edm_rate_limit_*` can switch limiting off by
returning `0` — they are floored at 1; use `wc_edm_rate_limit_enabled`.
Returning `false` from `wc_edm_audit_log_enabled` on a shop that has a
compliance reason to keep the log is a decision, not a performance tweak.
## The family's shared hooks
These cross a repository boundary, which is the class of value nobody's checks
cover, so both directions are written out.
| hook | this plugin | what that means |
| --- | --- | --- |
| `wpmgr_menu_title` | **answers** it (main file) and **provides** it (our copy of `shared-menu.php`) | returns `Nolimit`, so the shared parent menu is labelled the same whichever sibling registered it |
| `wpmgr_dashboard_cards` | **answers** it (`class-admin.php`) and **provides** it (our copy of `shared-menu.php`) | adds this plugin's card — colour `#1a7a52`, two counts, cached 5 minutes |
| `wpmgr_menu_glyph` | **provides** it only, via our copy of `shared-menu.php` | this plugin does not override the glyph; it answers with the shared default `∞` |
**Why "provides" and "answers" are both true.** `includes/shared-menu.php` is
byte-identical in all eight family plugins and frozen at a SHA in
`ci/plugin.conf`. Whichever copy loads first is the one that runs, so this
plugin is simultaneously one of the eight possible providers of these three
hooks and a consumer of whichever copy won. That is why the label is set with a
filter and not by editing the shared file: editing it would make a site with
three of eight plugins updated show a different menu title depending on
activation order, and change its mind when a plugin is deactivated.
**What a consumer must not do.** Do not edit `shared-menu.php` to change any of
these — it reaches every site in activation order and the frozen SHA will fail
the build in all eight trees. Answer the filter from your own plugin.
`wpmgr_menu_title` must be idempotent: every sibling adds a callback and the
order is not defined.
## One core hook, because it is shared
| hook | this plugin | why it is in a contract document |
| --- | --- | --- |
| `update_plugins_{$hostname}` | **answers** it, `add_filter( …, 10, 3 )` | 🔴 the filter name is built from the *hostname*, not the repository, so every plugin updating from `gitea.nolimit.dk` lands on the same hook and WordPress runs every callback for every one of them |
Answer it unconditionally and you offer your release to your neighbour. On a
live shop in September 2026 the subscriptions plugin was told version 1.4.4 was
an update for itself; 1.4.4 was this plugin's. Load order decides who wins, so
a rename is enough to flip which plugin gets corrupted — and flipped toward one
with auto-updates on, it installs a stranger's zip over itself unattended.
The guard is two halves and both are required:
```php
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
// and inside offer_update():
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
```
Leave the argument count at 2 and `$plugin_file` never arrives, so the guard
silently does nothing — the same shape of bug it fixes. **No integration test
can catch this**, because a test site installs one plugin from that host and the
collision cannot arise; the assertion is the defence.
`plugins_api` is also answered and is safe, because it guards on `$args->slug`.
## What the checker cannot see, and is therefore listed by hand
`ci/check-docs.php` reads hook names out of the shipped source as string
literals. Two names in this tree are not literals at the call site:
| hook | how it is written | why it is missed |
| --- | --- | --- |
| `wc_edm_prune_audit_log` | `add_action( self::CRON_HOOK, … )` | a class constant, resolved at runtime |
| `update_plugins_{$hostname}` | `add_filter( "update_plugins_{$host}", … )` | interpolated from a variable |
Both rows above are real and both would survive a rename of the constant or the
variable without the checker noticing. `wc_edm_prune_audit_log` is the daily
audit-log prune event; it is internal wiring rather than an extension point,
but it is a name that appears in `wp_next_scheduled()` output and in any cron
viewer, so it is written down.
This section exists because the instrument's blind spot is worth stating next
to its results. A generated list is better than a remembered one, and it is
still a list of what the generator can see.