Skip to content

Commit cdc87e5

Browse files
authored
Close Standard.site compatibility gaps (#167)
1 parent ce02546 commit cdc87e5

17 files changed

Lines changed: 796 additions & 22 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Shared posts on Bluesky now keep your site's publication details up to date automatically when you publish, so Standard.site readers always see current information.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: changed
3+
4+
Standard.site records and OAuth permissions are now more compatible with current long-form publishing tools and discovery.

docs/developer-docs.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,17 @@ ATmosphere exposes a small set of filters and actions for plugins to extend beha
3535
|------|------|-----|
3636
| `atmosphere_content_parser` | filter | Deprecated parser hook; use `Content_Parser\Registry::register()` instead. |
3737
| `atmosphere_document_content` | filter | Last-chance modification of the parsed content object. |
38+
| `atmosphere_document_links` | filter | Add a typed `links` union to `site.standard.document` records. |
39+
| `atmosphere_document_labels` | filter | Add standard self-labels to `site.standard.document` records. |
40+
| `atmosphere_document_contributors` | filter | Add contributor metadata to `site.standard.document` records. |
41+
| `atmosphere_publication_labels` | filter | Add standard self-labels to `site.standard.publication` records. |
42+
| `atmosphere_publication_show_in_discover` | filter | Override `preferences.showInDiscover` (defaults to the site's `blog_public` option) for `site.standard.publication` records. |
3843
| `atmosphere_syncable_post_types` | filter | Add or remove post types eligible for cross-posting. |
3944
| `atmosphere_should_publish_comment` | filter | Customise which approved comments are mirrored as Bluesky replies. |
4045
| `atmosphere_should_sync_reply` | filter | Customise which inbound Bluesky replies become WordPress comments. |
4146
| `atmosphere_transform_bsky_post` | filter | Mutate the Bluesky post record before write. |
4247
| `atmosphere_transform_document` | filter | Mutate the document record before write. |
48+
| `atmosphere_transform_publication` | filter | Mutate the publication record before write. |
4349
| `atmosphere_appview_host` | filter | Point Bluesky web links at an alternative AT Protocol appview (host or subpath). |
4450
| `atmosphere_appview_url` | filter | Rewrite the whole assembled appview link, including its route. |
4551
| `atmosphere_publish_post_result` | action | React to a post-publish outcome (success or `WP_Error`). |
@@ -102,6 +108,71 @@ add_filter(
102108

103109
Of note: links rendered on the fly (facet mentions, hashtags, and the "View on Bluesky" link) pick up the filters on every render, so changing them updates immediately. The author and source links stored on synced reaction comments are resolved once at sync time, so they keep whichever host was in effect when the comment was synced.
104110

111+
### Extending Standard.site metadata
112+
113+
ATmosphere emits the core `site.standard.publication` and `site.standard.document` fields from WordPress data. Optional Standard.site fields that do not have a native WordPress source are extension points.
114+
115+
Document metadata filters:
116+
117+
```php
118+
add_filter(
119+
'atmosphere_document_links',
120+
static fn( $links, \WP_Post $post ) => array(
121+
'$type' => 'example.document.links',
122+
'items' => array(
123+
array( 'uri' => 'https://example.com/source' ),
124+
),
125+
),
126+
10,
127+
2
128+
);
129+
130+
add_filter(
131+
'atmosphere_document_labels',
132+
static fn() => array(
133+
'$type' => 'com.atproto.label.defs#selfLabels',
134+
'values' => array(
135+
array( 'val' => 'adult' ),
136+
),
137+
)
138+
);
139+
140+
add_filter(
141+
'atmosphere_document_contributors',
142+
static fn( $contributors, \WP_Post $post ) => array(
143+
array(
144+
'did' => 'did:plc:editor123',
145+
'role' => 'editor',
146+
'displayName' => 'Jane Editor',
147+
),
148+
),
149+
10,
150+
2
151+
);
152+
```
153+
154+
Publication metadata filters:
155+
156+
```php
157+
add_filter(
158+
'atmosphere_publication_labels',
159+
static fn() => array(
160+
'$type' => 'com.atproto.label.defs#selfLabels',
161+
'values' => array(
162+
array( 'val' => 'adult' ),
163+
),
164+
)
165+
);
166+
167+
// `showInDiscover` defaults to the site's `blog_public` option; force it
168+
// off (or return null to omit the preference entirely) regardless.
169+
add_filter( 'atmosphere_publication_show_in_discover', '__return_false' );
170+
```
171+
172+
The field-specific filters run before `atmosphere_transform_document` and `atmosphere_transform_publication`, so a final record-level filter can still inspect or override the complete record.
173+
174+
ATmosphere models one root publication per WordPress site. It verifies that publication at `/.well-known/site.standard.publication` and does not currently implement Standard.site's non-root publication verification path (`/.well-known/site.standard.publication/path/to/publication`). Social Standard.site lexicons such as `site.standard.graph.subscription` and `site.standard.graph.recommend` are also out of scope for the plugin's publishing flow; ATmosphere requests explicit `repo:` scopes only for `app.bsky.feed.post`, `site.standard.document`, and `site.standard.publication`, and intentionally keeps the documented `include:site.standard.authFull` permission set for Standard.site compatibility even though it does not publish or manage social records itself.
175+
105176
## Extending Content Formats
106177

107178
The `site.standard.document` record's `content` field is a singular open union of typed content objects (see [`docs/content-formats.md`](content-formats.md)). ATmosphere ships built-in parsers for HTML, Markpub, Leaflet, and pckt formats, and integrations can register additional parsers.

docs/php-coding-standards.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ use function Atmosphere\is_connected;
211211
```php
212212
\apply_filters( 'atmosphere_content_parser', $parser, $post ); // Deprecated; use Registry::register().
213213
\apply_filters( 'atmosphere_document_content', $content, $post, $parser );
214+
\apply_filters( 'atmosphere_document_links', null, $post );
215+
\apply_filters( 'atmosphere_document_labels', null, $post );
216+
\apply_filters( 'atmosphere_document_contributors', null, $post );
217+
\apply_filters( 'atmosphere_publication_labels', null );
218+
\apply_filters( 'atmosphere_publication_show_in_discover', (bool) \get_option( 'blog_public', 1 ) );
214219
\apply_filters( 'atmosphere_long_form_composition', $composition, $post );
215220
\apply_filters( 'atmosphere_teaser_thread_posts', $max_posts, $post );
216221
```

includes/class-atmosphere.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,9 @@ public function output_document_link(): void {
334334
* - Singular publishable posts, so a resolver landing on an article
335335
* URL can find the parent publication directly without first
336336
* fetching the document record.
337-
* - The WordPress front page, since the publication record's `url`
338-
* field is `home_url('/')`. Lets a resolver verify the page <->
339-
* publication binding by matching AT-URIs, sparing the
337+
* - The WordPress front page, which is the local page represented
338+
* by the normalized publication URL. Lets a resolver verify the
339+
* page <-> publication binding by matching AT-URIs, sparing the
340340
* `.well-known/site.standard.publication` round-trip.
341341
*
342342
* Gated on `has_identity()` (not `is_connected()`) so the

includes/class-publisher.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ public static function publish_post( \WP_Post $post ): array|\WP_Error {
105105
return $result;
106106
}
107107

108+
// Heal a drifted publication record before composing the post,
109+
// so the embedded publication strongRef points at the current CID.
110+
self::maybe_heal_publication();
111+
108112
$bsky_transformer = new Post( $post );
109113
$doc_transformer = new Document( $post );
110114

@@ -741,6 +745,12 @@ public static function update_post( \WP_Post $post ): array|\WP_Error {
741745
return self::publish_post( $post );
742746
}
743747

748+
// In-place update path: heal a drifted publication record before
749+
// composing the post so the embedded publication strongRef points
750+
// at the current CID. The fresh-publish branch above already heals
751+
// through its own `publish_post()` call.
752+
self::maybe_heal_publication();
753+
744754
foreach ( $stored as $entry ) {
745755
if ( empty( $entry['tid'] ) ) {
746756
return new \WP_Error(
@@ -1596,6 +1606,57 @@ public static function sync_publication(): array|\WP_Error {
15961606
return $result;
15971607
}
15981608

1609+
/**
1610+
* Re-sync the publication record when it has drifted from the
1611+
* record the current transform would produce.
1612+
*
1613+
* `sync_publication()` otherwise only runs on a handful of settings
1614+
* hooks (site title, tagline, icon, theme — {@see Atmosphere::schedule_publication_sync()}).
1615+
* A change that doesn't fire one of those never reaches the PDS: a
1616+
* plugin update that alters the record shape, a newly-registered
1617+
* `atmosphere_transform_publication` / `atmosphere_publication_*`
1618+
* filter, or the publication-URL normalisation shipped in this
1619+
* release. The live publication record — and the publication
1620+
* strongRef every long-form post embeds in `associatedRefs` — would
1621+
* stay frozen at the last settings-triggered sync, leaving the
1622+
* Bluesky post pointing at a stale publication CID and standard.site
1623+
* unable to verify the document against the publication URL.
1624+
*
1625+
* Detect that drift on the publish path and heal it before the
1626+
* post's `associatedRefs` are built, so the post points at the
1627+
* refreshed publication CID. Cheap when already in sync: one local
1628+
* transform plus a DAG-CBOR encode, no network. A putRecord only
1629+
* fires when the locally-computed CID diverges from the last synced
1630+
* CID, and the sync's response refreshes `OPTION_CID` so the next
1631+
* publish sees no drift.
1632+
*/
1633+
private static function maybe_heal_publication(): void {
1634+
if ( '' === get_did() ) {
1635+
return;
1636+
}
1637+
1638+
/*
1639+
* No publication has ever been created for this site — there is
1640+
* nothing to heal yet. The first write happens through the
1641+
* connect / settings-save flow, which mints the TID.
1642+
*/
1643+
if ( '' === (string) \get_option( Publication::OPTION_TID, '' ) ) {
1644+
return;
1645+
}
1646+
1647+
$current = CID::from_record( ( new Publication( null ) )->transform() );
1648+
1649+
if ( \is_wp_error( $current ) ) {
1650+
return;
1651+
}
1652+
1653+
if ( (string) \get_option( Publication::OPTION_CID, '' ) === $current ) {
1654+
return;
1655+
}
1656+
1657+
self::sync_publication();
1658+
}
1659+
15991660
/**
16001661
* Publish a WordPress comment as an app.bsky.feed.post reply.
16011662
*

includes/oauth/class-client.php

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,91 @@ class Client {
2626
/**
2727
* Scopes requested from the auth server.
2828
*
29-
* `identity:handle` is required for `com.atproto.identity.updateHandle`
30-
* — the canonical AT Protocol permission scope per
31-
* https://atproto.com/specs/permission. `transition:generic` is the
32-
* App Password-equivalent bucket and explicitly does not include
33-
* identity operations, so it must be paired with `identity:handle`
34-
* for any flow that lets users change their handle through the PDS.
35-
*
3629
* MUST stay in lockstep with the `scope` value advertised in the
3730
* client-metadata REST endpoint
3831
* ({@see \Atmosphere\Rest\Client_Metadata_Controller::get_metadata()}). The auth
3932
* server validates the requested scope against the metadata; a drift
4033
* silently downgrades every connection to whichever value is smaller.
4134
*
42-
* @var string
35+
* @var string[]
4336
*/
44-
private const SCOPES = 'atproto transition:generic identity:handle';
37+
private const SCOPES = array(
38+
39+
/*
40+
* Baseline AT Protocol OAuth session.
41+
* Defined in the OAuth spec:
42+
* https://atproto.com/specs/oauth#authorization-scopes.
43+
*/
44+
'atproto',
45+
46+
/*
47+
* Write the Bluesky records ATmosphere publishes: posts, threads,
48+
* and comment replies.
49+
*
50+
* `repo` permissions: https://atproto.com/specs/permission#repo.
51+
*/
52+
'repo:app.bsky.feed.post',
53+
54+
/*
55+
* Write one Standard.site document record per synced WordPress post.
56+
*
57+
* `repo` permissions: https://atproto.com/specs/permission#repo.
58+
*/
59+
'repo:site.standard.document',
60+
61+
/*
62+
* Write the root Standard.site publication record for the WordPress
63+
* site.
64+
*
65+
* `repo` permissions: https://atproto.com/specs/permission#repo.
66+
*/
67+
'repo:site.standard.publication',
68+
69+
/*
70+
* Upload image blobs referenced by posts, document covers, and site
71+
* icons.
72+
*
73+
* `blob` permissions: https://atproto.com/specs/permission#blob.
74+
*/
75+
'blob:image/*',
76+
77+
/*
78+
* Resolve actor profile metadata while syncing and rendering inbound
79+
* reactions.
80+
*
81+
* `rpc` permissions: https://atproto.com/specs/permission#rpc.
82+
* Bluesky AppView DID:
83+
* https://docs.bsky.app/docs/advanced-guides/api-directory.
84+
*/
85+
'rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview',
86+
87+
/*
88+
* Read Bluesky notification pages for inbound reply/like/repost sync.
89+
*
90+
* `rpc` permissions: https://atproto.com/specs/permission#rpc.
91+
* Bluesky AppView DID:
92+
* https://docs.bsky.app/docs/advanced-guides/api-directory.
93+
*/
94+
'rpc:app.bsky.notification.listNotifications?aud=did:web:api.bsky.app%23bsky_appview',
95+
96+
/*
97+
* Update the PDS-managed handle when a user opts into a domain handle.
98+
*
99+
* `identity` permissions:
100+
* https://atproto.com/specs/permission#identity.
101+
*/
102+
'identity:handle',
103+
104+
/*
105+
* Standard.site's published full permission set. It also grants
106+
* social collections, but ATmosphere keeps the documented set for
107+
* Standard.site compatibility while leaving social record writes
108+
* out of its publishing flow.
109+
*
110+
* Permission set: https://standard.site/docs/permissions.
111+
*/
112+
'include:site.standard.authFull',
113+
);
45114

46115
/**
47116
* `wp_options` row name used as the cross-process refresh lock.
@@ -75,6 +144,15 @@ public static function client_id(): string {
75144
return \rest_url( 'atmosphere/v1/client-metadata' );
76145
}
77146

147+
/**
148+
* OAuth scopes requested by authorization and advertised in metadata.
149+
*
150+
* @return string
151+
*/
152+
public static function scopes(): string {
153+
return \implode( ' ', self::SCOPES );
154+
}
155+
78156
/**
79157
* OAuth callback URI (admin page with special query param).
80158
*
@@ -227,7 +305,7 @@ public static function authorize( string $handle ): string|\WP_Error {
227305
'client_id' => self::client_id(),
228306
'redirect_uri' => self::redirect_uri(),
229307
'response_type' => 'code',
230-
'scope' => self::SCOPES,
308+
'scope' => self::scopes(),
231309
'state' => $state,
232310
'code_challenge' => $challenge,
233311
'code_challenge_method' => 'S256',
@@ -266,7 +344,7 @@ private static function authorize_via_par(
266344
'client_id' => self::client_id(),
267345
'redirect_uri' => self::redirect_uri(),
268346
'response_type' => 'code',
269-
'scope' => self::SCOPES,
347+
'scope' => self::scopes(),
270348
'state' => $state,
271349
'code_challenge' => $challenge,
272350
'code_challenge_method' => 'S256',

includes/rest/class-client-metadata-controller.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public function get_metadata(): WP_REST_Response {
9595
* request scope against the metadata; a drift here
9696
* silently downgrades to the smaller of the two.
9797
*/
98-
'scope' => 'atproto transition:generic identity:handle',
98+
'scope' => Client::scopes(),
9999
'dpop_bound_access_tokens' => true,
100100
'application_type' => 'web',
101101
);

0 commit comments

Comments
 (0)