| @@ -5,66 +5,76 @@ | ||
| 5 | 5 | Plugin authors looking for the canonical route URL → handler map start here; the implementation file is one open away. |
| 6 | 6 | |
| 7 | 7 | ## Namespace |
| 8 | 8 | |
| 9 | -All in-tree routes register under `desktop-mode/v1`. Extensions are expected to register under `desktop-mode-<extension>/v1` (declared via the `extensions/base/Desktop_Mode_Extension_Rest` base's `namespace()` method; the base does not enforce a particular namespace — its docblock also permits `desktop-mode/v1`). | |
| 9 | +All in-tree routes register under `desktop-mode/v1`. Extensions are expected to register under `os-<extension>/v1` (declared via the `extensions/base/OpenStation_Extension_Rest` base's `namespace()` method; the base does not enforce a particular namespace — its docblock also permits `desktop-mode/v1`). | |
| 10 | 10 | |
| 11 | 11 | ## Routes |
| 12 | 12 | |
| 13 | 13 | | Route | Verb | Handler file | Permission | |
| 14 | 14 | |---|---|---|---| |
| 15 | -| `/session` | GET / POST / DELETE | `includes/session.php` | logged-in + desktop mode enabled | | |
| 16 | -| `/default-window` | POST | `includes/default-window.php` | logged-in + desktop mode enabled | | |
| 17 | -| `/intros/seen` | POST | `includes/seen-intros.php` | logged-in + desktop mode enabled | | |
| 18 | -| `/intros` | DELETE | `includes/seen-intros.php` | logged-in + desktop mode enabled | | |
| 19 | -| `/os-settings` | GET / POST | `includes/os-settings.php` | logged-in + desktop mode enabled | | |
| 15 | +| `/session` | GET / POST / DELETE | `includes/session.php` | logged-in + OpenStation enabled | | |
| 16 | +| `/default-window` | POST | `includes/default-window.php` | logged-in + OpenStation enabled | | |
| 17 | +| `/intros/seen` | POST | `includes/seen-intros.php` | logged-in + OpenStation enabled | | |
| 18 | +| `/intros` | DELETE | `includes/seen-intros.php` | logged-in + OpenStation enabled | | |
| 19 | +| `/os-settings` | GET / POST | `includes/os-settings.php` | logged-in + OpenStation enabled | | |
| 20 | 20 | | `/extended-options` | GET / POST | `includes/extended-options.php` | `manage_options` | |
| 21 | -| `/pwa-state` | GET / POST | `includes/pwa.php` | logged-in + desktop mode enabled | | |
| 22 | -| `/debug` | GET | `includes/devtools.php` | `manage_options` (filterable via `desktop_mode_debug_rest_permission`) | | |
| 23 | -| `/presence` | GET / POST | `includes/presence.php` | logged-in + desktop mode enabled | | |
| 21 | +| `/pwa-state` | GET / POST | `includes/pwa.php` | logged-in + OpenStation enabled | | |
| 22 | +| `/feedback/deactivation` | POST | `includes/feedback/rest.php` | `activate_plugins` + `openstation_deactivation_feedback_enabled()`; deliberately not `openstation_rest_require_enabled()` (the person deactivating usually has OpenStation off). No object-level checks: the route stores nothing on the site, it forwards an anonymous payload to the intake on openstation.blog and answers `{ sent }` | | |
| 23 | +| `/debug` | GET | `includes/devtools.php` | `manage_options` (filterable via `openstation_debug_rest_permission`) | | |
| 24 | +| `/presence` | GET / POST | `includes/presence.php` | logged-in + OpenStation enabled | | |
| 24 | 25 | | `/oauth/start` | POST | `includes/oauth-relay.php` | logged-in | |
| 25 | 26 | | `/oauth/callback` | GET | `includes/oauth-relay.php` | public (validated by the state nonce) | |
| 26 | -| `/term-counts` | GET | `includes/posts-window/window.php` | `edit_posts` | | |
| 27 | -| `/tag-cooccurrence` | GET | `includes/posts-window/window.php` | `edit_posts` | | |
| 28 | -| `/users` | POST | `includes/users-window/rest.php` | `create_users` | | |
| 29 | -| `/users/bulk-role` | POST | `includes/users-window/rest.php` | `promote_users` | | |
| 30 | -| `/users/bulk-delete` | POST | `includes/users-window/rest.php` | `delete_users` (`remove_users` on multisite) | | |
| 31 | -| `/users/{id}/send-password-reset` | POST | `includes/users-window/rest.php` | `edit_users` | | |
| 32 | -| `/users/{id}/resend-welcome` | POST | `includes/users-window/rest.php` | `edit_users` | | |
| 33 | -| `/users/{id}/insights` | GET | `includes/user-edit-window/rest.php` | `edit_user` on the target user | | |
| 34 | -| `/users/{id}/destroy-sessions` | POST | `includes/user-edit-window/rest.php` | `edit_user` on the target user | | |
| 35 | -| `/users/{id}/application-passwords` | GET / POST | `includes/user-edit-window/rest.php` | `edit_user` on the target user | | |
| 36 | -| `/users/{id}/application-passwords/{uuid}` | DELETE | `includes/user-edit-window/rest.php` | `edit_user` on the target user | | |
| 37 | -| `/comments/bulk` | POST | `includes/comments-window/rest.php` | `moderate_comments` | | |
| 38 | -| `/comments/reply` | POST | `includes/comments-window/rest.php` | `edit_posts` | | |
| 39 | -| `/comments/insights/{email}` | GET | `includes/comments-window/rest.php` | `moderate_comments` | | |
| 40 | -| `/comments/counts` | GET | `includes/comments-window/rest.php` | `edit_posts` | | |
| 41 | -| `/comments/ai-settings` | GET / POST | `includes/comments-window/ai-moderation.php` | `manage_options` | | |
| 42 | -| `/content-graph/post-types` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `desktop_mode_content_graph_user_can_use`) | | |
| 43 | -| `/content-graph/nodes` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `desktop_mode_content_graph_user_can_use`) | | |
| 44 | -| `/content-graph/post/{id}` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `desktop_mode_content_graph_user_can_use`) | | |
| 45 | -| `/comment-stats/{id}` | GET | `includes/my-wordpress/comment-stats.php` | logged-in | | |
| 46 | -| `/term-stats/{taxonomy}/{id}` | GET | `includes/my-wordpress/term-stats.php` | logged-in + `read` | | |
| 47 | -| `/user-stats/{id}` | GET | `includes/my-wordpress/user-stats.php` | logged-in | | |
| 48 | -| `/user-footprint/{id}` | GET | `includes/my-wordpress/user-footprint.php` | logged-in | | |
| 27 | +| `/term-counts` | GET | `apps/posts/parts/terms-rest.php` | `edit_posts` | | |
| 28 | +| `/tag-cooccurrence` | GET | `apps/posts/parts/terms-rest.php` | `edit_posts` | | |
| 29 | +| `/users` | POST | `apps/users/parts/rest.php` | `create_users` | | |
| 30 | +| `/users/bulk-role` | POST | `apps/users/parts/rest.php` | `promote_users` | | |
| 31 | +| `/users/bulk-delete` | POST | `apps/users/parts/rest.php` | `delete_users` (`remove_users` on multisite) | | |
| 32 | +| `/users/{id}/send-password-reset` | POST | `apps/users/parts/rest.php` | `edit_users` | | |
| 33 | +| `/users/{id}/resend-welcome` | POST | `apps/users/parts/rest.php` | `edit_users` | | |
| 34 | +| `/users/{id}/insights` | GET | `apps/user-edit/parts/insights.php` | `edit_user` on the target user | | |
| 35 | +| `/users/{id}/destroy-sessions` | POST | `apps/user-edit/parts/account.php` | `edit_user` on the target user | | |
| 36 | +| `/users/{id}/application-passwords` | GET / POST | `apps/user-edit/parts/account.php` | `edit_user` on the target user | | |
| 37 | +| `/users/{id}/application-passwords/{uuid}` | DELETE | `apps/user-edit/parts/account.php` | `edit_user` on the target user | | |
| 38 | +| `/comments/bulk` | POST | `apps/comments/parts/rest.php` | `moderate_comments` | | |
| 39 | +| `/comments/reply` | POST | `apps/comments/parts/rest.php` | `edit_posts` | | |
| 40 | +| `/comments/insights/{email}` | GET | `apps/comments/parts/rest.php` | `moderate_comments` | | |
| 41 | +| `/comments/counts` | GET | `apps/comments/parts/rest.php` | `edit_posts` | | |
| 42 | +| `/content-graph/post-types` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `openstation_content_graph_user_can_use`) | | |
| 43 | +| `/content-graph/nodes` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `openstation_content_graph_user_can_use`) | | |
| 44 | +| `/content-graph/post/{id}` | GET | `includes/content-graph/rest.php` | `edit_posts` (filterable via `openstation_content_graph_user_can_use`) | | |
| 45 | +| `/apps/(?P<app>[a-z0-9][a-z0-9_-]*)/dispatch` | POST | `includes/framework/wordpress.php` | Logged in + the app exists + `App::allows()` (its `capabilities()` and `can()` gate). Every `.os.php` window — Code Blue included, behind Developer mode + `manage_options` (`manage_network_options` on multisite), filterable via `openstation_code_blue_user_can_use` — is served by this one route; there are no per-app routes. | | |
| 46 | +| `/comment-stats/{id}` | GET | `includes/my-wordpress/comment-stats.php` | `edit_posts` (filterable via `openstation_my_wordpress_user_can_use`) + parent post readable: `read_post`, but `edit_post` when the parent is still sealed by a password (a caller who already entered it reads on `read_post` like anyone else) or when its post type has no readable front end, and `moderate_comments` when the parent is gone | | |
| 47 | +| `/term-stats/{taxonomy}/{id}` | GET | `includes/my-wordpress/term-stats.php` | `edit_posts` (filterable via `openstation_my_wordpress_user_can_use`), then narrowed per caller: a non-viewable taxonomy answers 400 unless the caller holds its `manage_terms`, and every post-level aggregate (`counts.posts`, `recent`) is scoped to the statuses the caller may read, with `recent` gated per row on `read_post`. The payload is viewer-dependent — never cache it under a term-only key | | |
| 48 | +| `/user-stats/{id}` | GET | `includes/my-wordpress/user-stats.php` | `edit_posts` (filterable via `openstation_my_wordpress_user_can_use`), then tiered in the handler on `list_users` (or the subject viewing their own dossier). Without it: no email / username / registered / roles, and every count and `recent` covers published content only. `counts.cpt` and both comment counts additionally require a viewable post type, since `publish` rows of an internal type have no front end, and the comment counts skip password-protected and deleted parents and ask the `/comment-stats` parent gate of each parent they count, so a plugin's per-post `read_post` filter moves them. Viewer-dependent payload | | |
| 49 | +| `/user-footprint/{id}` | GET | `includes/my-wordpress/user-footprint.php` | `edit_posts` (filterable via `openstation_my_wordpress_user_can_use`), then gated per post rather than per tier. A timeline row needs the caller to see its post: a public status of a viewable type, else `read_post`; `edit_post` for a type with no front end; the `/comment-stats` parent gate for comment rows, sealed and deleted parents included. Every count that can reach those posts (`totals`, each day's `comments` and `updates`, and the streak built from them) asks the same gate of each post it counts, so a count never reports activity the rows withhold, a plugin's per-post capability filter included. `list_users` (or self) only adds `roleLabels` / `registered`. Viewer-dependent payload | | |
| 49 | 50 | | `/media-usage/{id}` | GET | `includes/my-wordpress/media-usage.php` | `read_post` on the attachment | |
| 50 | 51 | | `/recycle-bin/*` | various | `includes/recycle-bin/rest.php` | `delete_posts` (per-route gate) | |
| 51 | -| `/files/*` | various | `includes/desktop-files/rest.php` | logged-in + desktop mode enabled (share routes add a sharing gate) | | |
| 52 | +| `/files/*` | various | `includes/desktop-files/rest.php` | logged-in + OpenStation enabled (share routes add a sharing gate) | | |
| 52 | 53 | | `/ai/search` | POST | `includes/ai-copilot/search.php` | logged-in + AI feature flag | |
| 53 | 54 | | `/ai/platform-settings` | GET / POST | `includes/ai-copilot/platform-settings.php` | `manage_options` | |
| 54 | -| `/games/{game}/scores` | GET / POST | `includes/games/rest.php` | logged-in + desktop mode enabled + `read` (filterable via `desktop_mode_games_rest_permission`) | | |
| 55 | +| `/games/{game}/scores` | GET / POST | `includes/games/rest.php` | logged-in + OpenStation enabled + `read` (filterable via `openstation_games_rest_permission`) | | |
| 55 | 56 | | `/games/{game}/playtime` | POST | `includes/games/rest.php` | same games gate | |
| 56 | 57 | | `/games/playtime` | GET | `includes/games/rest.php` | same games gate | |
| 57 | -| `/games/challenges` | GET / POST | `includes/games/rest.php` | same games gate (create also passes `desktop_mode_games_can_challenge`) | | |
| 58 | +| `/games/challenges` | GET / POST | `includes/games/rest.php` | same games gate (create also passes `openstation_games_can_challenge`) | | |
| 58 | 59 | | `/games/challenges/{id}/accept` | POST | `includes/games/rest.php` | same games gate + challenge recipient | |
| 59 | 60 | | `/games/challenges/{id}/decline` | POST | `includes/games/rest.php` | same games gate + challenge recipient | |
| 60 | 61 | | `/games/challenges/{id}/complete` | POST | `includes/games/rest.php` | same games gate + challenge recipient | |
| 61 | 62 | | `/games/users/search` | GET | `includes/games/rest.php` | same games gate | |
| 63 | +| `/agents` | GET / POST | `includes/agents/rest.php` | GET `edit_posts`, POST `edit_users` (both filterable; whole module behind the `agents` extended option) | | |
| 64 | +| `/agents/{id}` | GET / POST / DELETE | `includes/agents/rest.php` | GET `edit_posts`, POST/DELETE `edit_users` (filterable) | | |
| 65 | +| `/agents/{id}/invoke` | POST | `includes/agents/rest.php` | `edit_posts` (filterable via `openstation_agents_user_can_invoke`), then the per-agent gate `openstation_agent_user_can_invoke_agent()` (honours the trigger's `capability`) + per-invoker and per-agent rate limits. The run itself is ceilinged at the caller's own capabilities — see [`docs/agents-security.md`](../../docs/agents-security.md) | | |
| 66 | +| `/agents/{id}/jobs/{jobId}` | GET | `includes/agents/jobs.php` | Authenticated invoker + exact job owner and agent match. Constant-cost status read, no worker dispatch. | | |
| 67 | +| `/agents/abilities` | GET | `includes/agents/rest.php` | `edit_posts` (filterable) | | |
| 68 | +| `/agents/draft` | POST | `includes/agents/rest.php` | `edit_users` (filterable). One AI generate call with a strict answer schema; creates nothing. `503` without the AI Client, `502` when the provider fails or answers unreadably | | |
| 69 | +| `/agents/trigger-kinds` | GET | `includes/agents/rest.php` | `edit_posts` (filterable) | | |
| 70 | +| `/agents/hooks-catalogue` | GET | `includes/agents/rest.php` | `edit_posts` (filterable) | | |
| 71 | +| `/agents/roles` | GET | `includes/agents/rest.php` | `edit_users` (filterable) | | |
| 62 | 72 | |
| 63 | 73 | ## Conventions |
| 64 | 74 | |
| 65 | 75 | - **Nonce.** Every state-changing route requires `X-WP-Nonce` (the standard REST nonce). Read routes that depend on per-user state also require it. |
| 66 | -- **Permission.** Permission callbacks use either `is_user_logged_in()` + capability checks or domain predicates. Shell-internal endpoints that only touch the caller's own per-user desktop state (`/session`, `/default-window`, `/intros`, `/os-settings`, `/pwa-state`, `/presence`) share the `desktop_mode_rest_require_enabled()` gate (`includes/helpers.php`): logged-in **and** `desktop_mode_is_enabled()`, returning `401` when logged out and `403` when desktop mode is off. `read` alone is deliberately not enough — every authenticated role carries it. Filtering with `desktop_mode_*` hooks lets plugins extend or harden access. | |
| 76 | +- **Permission.** Permission callbacks use either `is_user_logged_in()` + capability checks or domain predicates. Shell-internal endpoints that only touch the caller's own per-user desktop state (`/session`, `/default-window`, `/intros`, `/os-settings`, `/pwa-state`, `/presence`) share the `openstation_rest_require_enabled()` gate (`includes/helpers.php`): logged-in **and** `openstation_is_enabled()`, returning `401` when logged out and `403` when OpenStation is off. `read` alone is deliberately not enough — every authenticated role carries it. Filtering with `openstation_*` hooks lets plugins extend or harden access. | |
| 67 | 77 | - **Errors.** Failures return `WP_Error` with a stable `code`, a translated `message`, and a `data: { status: <int> }` block. Codes are documented per-endpoint in `docs/hooks-reference.md`. |
| 68 | 78 | |
| 69 | 79 | ## Why no central registration |
| 70 | 80 | |
| @@ -69,5 +79,7 @@ | ||
| 69 | 79 | ## Why no central registration |
| 70 | 80 | |
| 71 | 81 | PHP `register_rest_route()` calls execute on `rest_api_init`. The callback closures in the existing files capture per-module state — the recycle-bin store, the desktop-files registry, the AI provider — that lives in the same module. Moving the registration calls out of those files would force every callback to re-look-up its dependencies, increasing surface area without reducing coupling. The route → handler-file map above is the discoverability win we wanted; the per-module registrations are the layout that minimises blast radius. |
| 72 | 82 | |
| 73 | -If a future extension adds REST routes that don't fit any existing module, the `extensions/base/Desktop_Mode_Extension_Rest` base class is the cheapest path. See `extensions/base/README.md`. | |
| 83 | +If a future extension adds REST routes that don't fit any existing module, the `extensions/base/OpenStation_Extension_Rest` base class is the cheapest path. See `extensions/base/README.md`. | |
| 84 | + | |
| 85 | +Agent invocation supports `async: true` plus a UUID `requestId`, returning HTTP 202. See [async jobs](../../docs/architecture.md#async-agent-jobs) for the status contract and worker requirements. | |