Conversation
`is_connected()` reads three stored fields and never leaves the site, which is the right answer almost everywhere. It cannot answer the one question the pre-publish panel is actually asked. A refresh token the user revoked from their Bluesky account is byte-for-byte identical on disk to a working one — revocation happens at the auth server and writes nothing locally — so the panel would promise a share that was already impossible, and the author only found out afterwards. `verify_connection()` asks. `com.atproto.server.getSession` is the cheapest authenticated read the PDS offers, and running it through the ordinary request path means a dead session travels the existing 401 → refresh → `mark_needs_reauth()` route on its own. The probe records nothing itself; its whole job is to make local state honest before the decision reads it, so every surface already built on `is_connected()` reports the truth with the copy it already has. Failure is not evidence. A timeout, a 5xx, or a rate limit says the check did not complete, not that the session is dead, so those leave the stored state untouched — blocking an author over our own inability to ask would be worse than the staleness this fixes. The verdict is cached for fifteen minutes, and only ever on success: a permanent rejection lives on the connection row, where it needs no cache. The controller's fixture held a placeholder access token that nothing could decrypt. Now that the endpoint exercises the credentials, that is a broken connection rather than a connected site, so it holds real encrypted ones.
Three follow-ups from review. The cached verdict survived a reconnect. Only `disconnect()` cleared it, but neither connect surface goes through `disconnect()` — the settings field and the Connectors card both authorize straight over a live connection. Switching accounts therefore inherited the previous session's clean bill of health for the rest of the TTL, which is exactly the staleness the probe exists to remove. The probe also ran on answers that could never depend on it. Every gate above the connection check returns without consulting it, so a site with sharing switched off, or a private, password-protected, or opted-out post, was spending a live PDS round-trip on a result nothing reads. It now runs last, and the site status is re-resolved afterwards so a session it just flagged reports the flagged reason rather than the one read before the probe. Documents the gap this does not close: deactivated, suspended, and taken-down accounts come back as 400/403 rather than 401, so they never enter the refresh ladder and nothing flags the connection. Acting on `getSession`'s `active`/`status` fields needs new copy on every surface, so a test pins the current behaviour instead, and closing the gap stays a deliberate change with a failing test behind it.
There was a problem hiding this comment.
🟢 Approval recommended
The changes are cohesive, use existing error/refresh plumbing correctly, and are backed by targeted unit and integration tests covering the key failure modes and cache invalidation paths.
Pull request overview
This PR makes the pre-publish panel’s “will this share to Bluesky?” answer reflect the actual session state by performing a lightweight authenticated probe (com.atproto.server.getSession) before the panel reads local connection state. It uses the existing auth/refresh/error-handling pipeline so that revoked sessions are surfaced via the already-established needs_reauth() / is_connected() semantics, with success-only caching to avoid excessive round-trips.
Changes:
- Add
API::get_session()to callcom.atproto.server.getSessionthrough the standard authenticated request path. - Add
Atmosphere\verify_connection()(success-only cached) and use it in the pre-publish decision flow as the final gate. - Clear the verification cache on both disconnect and reconnect, and add PHPUnit coverage for the new probe behavior and REST endpoint integration.
File summaries
| File | Description |
|---|---|
includes/class-api.php |
Adds get_session() helper to hit com.atproto.server.getSession via existing request/refresh machinery. |
includes/functions.php |
Introduces verify_connection() plus constants for transient key and TTL, implementing success-only caching and fail-open behavior on inconclusive errors. |
includes/rest/admin/class-pre-publish-controller.php |
Moves the “connected?” decision to be based on a live verification probe (as the last gate) and re-resolves share status after probing. |
includes/oauth/class-client.php |
Clears the session-verification transient on OAuth callback and on disconnect to prevent inheriting a prior account’s cached verdict. |
tests/phpunit/tests/class-test-verify-connection.php |
Adds a focused test suite covering cache behavior, revoked-session path, inconclusive failures, and cache clearing on reconnect/disconnect. |
tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php |
Updates fixtures to use decryptable credentials and adds end-to-end tests for revoked-session detection and fail-open unreachable-PDS behavior. |
.github/changelog/add-verify-connection-before-publish |
Adds an end-user changelog entry describing the improved pre-publish connection check. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Verifying at the pre-publish panel was too late to be much use: the author has already written the post and clicked Publish by then. The point of catching a dead connection is to catch it before any of that work happens, so the probe now runs in `Block_Editor::script_data()`, before the share status the banner renders is resolved. An author with a revoked session sees the reconnect prompt on the first paint of the editor, with nothing invested yet. The pre-publish call stays. It costs nothing behind the shared cache and still covers a session that dies mid-session, after the editor loaded. Not gated on whether sharing is switched on: the connection is shared infrastructure and the reconnect prompt is connection level, not cross-posting level. The enqueue already limits this to editors for supported post types. Bounds the probe with a 5-second timeout rather than the shared 30-second one. This now runs while the editor renders, which is the difference between a slow load and a frozen one, and the probe fails open — so giving up early costs a stale verdict for one cache window while waiting costs the author their editor.
Proposed changes:
The editor decided whether a post would reach Bluesky by reading three stored fields and never leaving the site. That is the right answer almost everywhere, but it cannot answer the one question the panel is actually asked.
A refresh token the user revoked from their Bluesky account is byte-for-byte identical on disk to a working one. Revocation happens at the auth server and writes nothing locally, so
is_connected()has no way to see it. The panel would promise a share that was already impossible, and the author only found out afterwards.So the editor asks, when it opens.
API::get_session()wrapscom.atproto.server.getSession, the cheapest authenticated read the PDS offers.Atmosphere\verify_connection()runs it through the ordinary request path, which matters: a dead session travels the existing 401 → refresh →mark_needs_reauth()ladder on its own. The probe records nothing itself. Its whole job is to make local state honest before the decision reads it, so every surface already built onis_connected()andneeds_reauth()reports the truth with the copy it already has. No new strings, nothing new to translate.Block_Editor::script_data()calls it before resolving the share status the document panel renders, so an author with a revoked session gets the reconnect prompt on the first paint — nothing written, nothing invested. The pre-publish panel calls it too; behind the shared cache that costs nothing and still covers a session that dies after the editor loaded.Design notes worth reviewing
It fails open. A timeout, a 5xx, or a rate limit says the check did not complete, not that the session is dead. Those leave stored state untouched and the panel still says the post will publish. Blocking an author because our own probe could not get through would be a worse bug than the staleness this fixes.
It reads the outcome instead of classifying the error. On
WP_Error,verify_connection()re-readsis_connected()rather than inspecting the error code. When the request path gives up on the credentials it flags the row itself, and that flag is what every other caller sees. This keeps the helper correct no matter which layer decided, and picks up any future path reaching the same conclusion by another route.Timeout. Bounded at 5 seconds rather than the shared 30. This runs while the editor renders, which is the difference between a slow load and a frozen one, and since the probe fails open, giving up early costs a stale verdict for one cache window while waiting costs the author their editor.
Where it is and is not gated. In the pre-publish decision it runs last, after every local gate — those all return without consulting the connection, so a private or opted-out post would otherwise spend a round-trip on an answer nothing reads. On editor load it is deliberately not gated on whether sharing is switched on: the connection is shared infrastructure and the reconnect prompt is connection level, not cross-posting level. The enqueue already limits it to editors for supported post types.
Caching. Successes only, 15 minutes, filterable via
atmosphere_session_verify_ttl. A permanent rejection needs no cache entry: it lives on the connection row, whereis_connected()reads it without touching the network. Cleared on bothdisconnect()andhandle_callback()— neither connect surface goes throughdisconnect(), so without the second one a reconnect (especially an account switch) would inherit the previous session's verdict.Known gap, deliberately not closed
This verifies that the credentials still authenticate, not that the account may post. A deactivated, suspended, or taken-down account is rejected as a 400/403 rather than a 401, so it never enters the refresh ladder and nothing flags the connection — the probe reports healthy.
getSessioncarriesactiveandstatusfields that would settle it, but acting on them means new copy on several surfaces.test_taken_down_account_is_a_known_gappins the current behaviour so closing it stays a deliberate change with a failing test behind it.Other information:
tests/phpunit/tests/class-test-block-editor.phpcovers the editor-load path: a revoked session surfacing asneeds_reconnecton first paint, an unreachable PDS not being read as a disconnection, and the short timeout actually reaching the transport.tests/phpunit/tests/class-test-verify-connection.php(10 tests) covers the disconnected short-circuit, cache hit /$forcebypass / zero-TTL, the revoked-session path, inconclusive 5xx and 429 handling, cache clearing on both disconnect and reconnect, and the documented takedown gap. Two more inclass-test-pre-publish-controller.phpcover the endpoint end-to-end.One fixture change worth a look: that controller's
set_up()held'access_token' => 'test-token', a placeholder nothing can decrypt. Now that the endpoint exercises the credentials, that is a broken connection rather than a connected site, and two tests failed. It holds real encrypted credentials instead. Every test covering the disconnected /needs_reauthstates still exitsverify_connection()at theis_connected()early return before any network call, so none of their assertions were weakened.Testing instructions:
Automated
npm run env-test -- --filter='Test_Verify_Connection|Test_Pre_Publish_Controller|Test_Block_Editor'Full suite and lint clean: 1381 tests, PHPCS 43/43.
Two of these were verified red before their fix —
test_revoked_session_fails_verification_and_flags_the_connectionandtest_reconnect_clears_the_cached_verdict.Manual
Before: the ATmosphere panel says the post will be shared. You write it, click Publish, confirm, and the share fails afterwards.
After: the panel carries the reconnect prompt as soon as the editor loads, before anything is written.
To check the fail-open path, block outbound requests to the PDS instead of revoking. The panel should still say the post will publish.
To check the reconnect path, reconnect without disconnecting first and confirm the panel re-probes rather than reusing the previous verdict.
Changelog entry
Committed on the branch as
.github/changelog/add-verify-connection-before-publish.