fix: dossier page 403 — options.php-parent pattern (v0.19.3)
Dossier page dead on prod (403) since v0.19.2 deploy. Root cause:
on_register_menu() registered under 'prautoblogger-settings' parent then
unset the slug from $submenu to hide the nav entry. At request time
get_admin_page_parent() scanned $submenu, found nothing, fell back to
orphan hookname 'admin_page_prautoblogger-dossier'. Render callback was
registered under 'prautoblogger_page_prautoblogger-dossier'. Mismatch ->
wp_die(403). Same hookname-mismatch class as the board 404 (v0.19.1).
Fix (seq 21 §Required fix items 1-6):
1. options.php-parent: add_submenu_page('options.php', ...) -> hookname
is 'admin_page_prautoblogger-dossier' at BOTH registration and request
time. Hidden by construction; no $submenu mutation needed or permitted.
Add PARENT_SLUG = 'options.php' constant.
2. Asset enqueue gate: updated from 'prautoblogger_page_*' to
'admin_page_*' prefix to match options.php-parent hookname.
3. Deep-link URLs (admin.php?page=prautoblogger-dossier&post_id=N):
parent-agnostic; unchanged. Asserted in
test_url_for_post_contains_slug_and_post_id +
test_url_for_post_exact_shape.
4. Regression test upgraded to request-time faithful:
test_request_time_hookname_matches_registration_hookname simulates
get_admin_page_parent() against post-registration $submenu state.
FAILS on v0.19.2 hide-by-unset (4 failures, captured in evidence):
- test_request_time_hookname_matches_registration_hookname FAIL
- test_dossier_parent_slug_is_options_php FAIL
- test_no_submenu_mutation_after_registration FAIL
- test_asset_enqueue_gate_uses_admin_page_prefix FAIL
PASSES on v0.19.3 options.php-parent (9/9 OK, captured in evidence).
test_hide_by_unset_would_fail_request_time_check documents broken
pattern inline.
5. ARCHITECTURE.md §22b: hidden-page convention cross-reference + v0.19.3
incident record added. §23: page registration description updated to
options.php-parent. File tree entry updated.
CONVENTIONS.md §Hidden Admin Pages: full rule + both incident history
(board 404 v0.19.1, dossier 403 v0.19.2) + test methodology.
6. Version 0.19.2 -> 0.19.3, CHANGELOG entry. tests/bootstrap.php:
PRAUTOBLOGGER_PLUGIN_URL + PRAUTOBLOGGER_VERSION constants added
(resolves 'Undefined constant' in enqueue tests).
Deletion audit: removed lines are the broken 'prautoblogger-settings'
parent string, the 12-line $submenu mutation block, the wrong hook prefix
'prautoblogger_page_*'. No other logic removed.
Agent-Session: dossier-403-hotfix-20260612
This commit is contained in:
@@ -6,10 +6,25 @@ declare(strict_types=1);
|
||||
*
|
||||
* Registers and renders the Article Dossier admin page (M2).
|
||||
*
|
||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It registers
|
||||
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
|
||||
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
|
||||
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
|
||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
|
||||
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
||||
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
||||
* BOTH registration time AND request time. The page is hidden from nav by
|
||||
* construction (options.php is a built-in WP page with no visible submenu block)
|
||||
* so no $submenu mutation is needed or permitted.
|
||||
*
|
||||
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
|
||||
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
|
||||
* post-registration unset($submenu['prautoblogger-settings'][$idx])
|
||||
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
|
||||
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
|
||||
* → no callback registered there → wp_die(403).
|
||||
* See ARCHITECTURE.md §22c + CONVENTIONS.md §Hidden Admin Pages for the full incident
|
||||
* history (board 404 v0.19.1, dossier 403 v0.19.2).
|
||||
*
|
||||
* Board cards and post-metabox deep-link here via
|
||||
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
|
||||
* (they reference only the page slug) so they are unchanged by this fix.
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||
@@ -19,46 +34,44 @@ declare(strict_types=1);
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||
* @see templates/admin/dossier-page.php -- HTML template.
|
||||
* @see ARCHITECTURE.md -- §Dossier (M2).
|
||||
* @see ARCHITECTURE.md -- §22c (hidden admin page convention).
|
||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Page {
|
||||
|
||||
/** Admin page slug. */
|
||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||
|
||||
/**
|
||||
* Parent slug used at registration.
|
||||
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
|
||||
* to `admin_page_{slug}` at both registration time and request time, so there is
|
||||
* no hookname mismatch and no need to manipulate $submenu.
|
||||
*/
|
||||
public const PARENT_SLUG = 'options.php';
|
||||
|
||||
/** Nonce action for any future stateful actions. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||
|
||||
/**
|
||||
* Register the dossier as a hidden submenu page.
|
||||
* Register the dossier as a hidden admin page.
|
||||
*
|
||||
* Hidden = not visible in the menu, but reachable by URL.
|
||||
* Priority 12 ensures add_menu_page() (priority 10) has populated
|
||||
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
|
||||
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
||||
* both registration time and request time. No $submenu manipulation required
|
||||
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
'prautoblogger-settings',
|
||||
self::PARENT_SLUG,
|
||||
__( 'Article Dossier', 'prautoblogger' ),
|
||||
__( 'Dossier', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
// Remove from visible submenu so it doesn't clutter the nav.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
|
||||
global $submenu;
|
||||
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
|
||||
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||
unset( $submenu['prautoblogger-settings'][ $idx ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +84,7 @@ class PRAutoBlogger_Dossier_Page {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
||||
$dossier = $assembler->assemble( $post_id );
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
||||
@@ -80,11 +93,14 @@ class PRAutoBlogger_Dossier_Page {
|
||||
/**
|
||||
* Enqueue dossier-specific CSS + JS.
|
||||
*
|
||||
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$dossier_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
||||
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
|
||||
if ( $hook_suffix !== $dossier_hook ) {
|
||||
return;
|
||||
}
|
||||
@@ -108,6 +124,9 @@ class PRAutoBlogger_Dossier_Page {
|
||||
/**
|
||||
* Build the URL to the dossier for a given post.
|
||||
*
|
||||
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
|
||||
* This method is unchanged from v0.19.2 -- only the registration parent changed.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string Admin URL to the dossier page.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user