# WordPress

Already have a WordPress site? Point your blog at it and every post you
publish here is pushed there automatically — as a real WordPress post, in
your theme, at your permalinks. **No plugin to install.** WordPress core
has shipped everything needed since 5.6: the REST API and Application
Passwords.

:::callout{severity=info title="You don't have to choose forever"}
Delivery mode is per blog and you can switch it back at any time. Nothing
about the content changes — the same reviewed, backlinked, MD2-rich post
either gets served on your domain by us, or written into your WordPress.
:::

## Connect in three steps

::::steps

:::step[Create an application password in WordPress]
In WordPress, go to **Users → Profile**, scroll to **Application
Passwords**, type a name (`baas`), and press **Add New Application
Password**. WordPress shows a 24-character password **once** — copy it now.

Connect as an **administrator** or **editor**. Those roles hold the
`unfiltered_html` capability, which is what lets your rich content arrive
intact — see **What survives the push** below.
:::

:::step[Paste it into the blog's delivery card]
On your blog page in baas, find **Where posts go**, switch delivery to
**Push to WordPress**, and fill in:

| Field | Value |
|---|---|
| WordPress site URL | Your site root, e.g. `https://example.com` — not the `/wp-admin` URL |
| WordPress username | The **login name** of that user, not their email |
| Application password | The 24-character string, spaces and all |
| Posts arrive as | `Published`, or `Draft` if you want a final look inside WordPress |

We store the password encrypted (AES-256-GCM) and never display it again —
the field stays blank on every later visit, and saving with it blank keeps
the stored one.
:::

:::step[Test the connection]
Press **Test connection**. A green result names the WordPress user we
authenticated as. Anything else tells you exactly what WordPress said —
see **Troubleshooting** below.
:::

::::

## What gets pushed, and when

A post is pushed when it **becomes published** — the moment you approve it
in the review queue, or the moment auto-publish publishes it for you. The
push carries:

| WordPress field | From |
|---|---|
| `title` | The post title |
| `slug` | The post slug — your permalink stays predictable |
| `content` | The rendered HTML, wrapped and styled (below) |
| `excerpt` | The generated excerpt |
| `status` | Whatever you chose in **Posts arrive as** |
| `date_gmt` | The publish time recorded here |

Pushing again **updates the same WordPress post** rather than creating a
second one: we remember the WordPress post id. If you delete the post
inside WordPress, the next push recreates it.

:::callout{severity=success title="A failed push never unpublishes anything"}
If WordPress is down, mid-update, or behind a firewall, the push retries
with backoff and the post stays exactly as published here. You see a red
**Failed** badge with the actual error, and a **Push again** button once
you have fixed it. Delivery problems are never allowed to become content
problems.
:::

### Taking a post down

**Take down** on a post pulls it here and on WordPress: the article stops
being served by us, and its WordPress copy is set back to **draft**, so it
answers 404 on your site while the content stays in your CMS. We never
delete or trash a post on your site — the draft keeps the same WordPress
post id, so **Publish again** pushes over the same post instead of creating
a second one.

If WordPress cannot be reached, the withdrawal retries with backoff and the
card shows the error; the post is already down here either way.

## What survives the push

Our posts are richer than plain HTML — callouts, step guides, comparison
panels, charts, annotated code. WordPress decides how much of that it
accepts based on one capability of the user you connected as:
`unfiltered_html`.

::::compare{title="Which user did you connect as?"}

:::option[Administrator or editor]{severity=success}
Holds `unfiltered_html` on a single-site install.

- Full component styling — we send a scoped `<style>` block
- Charts, flow diagrams, sparklines (SVG) all render
- Your [blog theme palette](/docs/theming) travels with the content
- The styles are scoped to our wrapper and cannot repaint your theme
:::

:::option[Author, contributor, or any multisite user]{severity=warning}
Lacks `unfiltered_html`, so WordPress runs your content through its KSES
filter.

- We deliberately send **no** `<style>` block — WordPress strips the tags
  but leaves the CSS behind as visible text, which would dump a stylesheet
  into your article
- SVG is removed entirely, so charts and diagrams disappear
- Text, headings, lists, tables, code, callout structure still arrive
:::

::::

The dashboard warns you about this after a connection test, so you find
out before your readers do. On WordPress **multisite**, only a Super Admin
holds `unfiltered_html` — that is a WordPress rule, not ours.

## SEO fields (Yoast and Rank Math)

We send the focus keyphrase, SEO title, and meta description for both
Yoast SEO and Rank Math on every push. Whether they stick is up to your
site:

:::callout{severity=warning title="Neither plugin accepts these over REST by default"}
Yoast's fields are WordPress "protected" meta and its own API is
read-only; Rank Math's are simply unregistered for REST. WordPress does
not reject unknown meta keys — it **silently drops** them. We read
WordPress's own response back to see what actually stored, so we never
claim a keyphrase landed when it did not.
:::

To make them stick, drop this must-use plugin into
`wp-content/mu-plugins/baas-seo-bridge.php` — byte-identical to the file
this project's own compose stack mounts into its WordPress dogfood target:

```php
<?php
/**
 * Plugin Name: baas SEO bridge
 * Description: Exposes the Yoast SEO and Rank Math post-meta fields over the
 *              WordPress REST API so an external publisher can set the focus
 *              keyphrase, SEO title, and meta description when it pushes a
 *              post. Without this, WordPress silently drops those keys. Also
 *              registers the _baas_post_id ownership marker so a retried push
 *              recognises a post it already created and updates it in place
 *              instead of publishing a duplicate — and never touches a post it
 *              did not create.
 * Version:     1.1.0
 * License:     MIT
 *
 * Drop this file in wp-content/mu-plugins/ — must-use plugins load
 * automatically and cannot be deactivated by accident.
 */

defined('ABSPATH') || exit;

add_action('init', function () {
    $keys = [
        // Yoast SEO. Leading underscore = protected meta, which the REST API
        // refuses to touch unless it is registered with an auth_callback.
        '_yoast_wpseo_focuskw',
        '_yoast_wpseo_metadesc',
        '_yoast_wpseo_title',
        // Rank Math. Not underscore-prefixed, but still unregistered for REST
        // on a stock install, so it needs the same treatment.
        'rank_math_focus_keyword',
        'rank_math_description',
        'rank_math_title',
        // baas ownership marker: our own post uuid, written on create and read
        // back (context=edit) so an id-less retry adopts only the post WE made.
        '_baas_post_id',
    ];

    foreach ($keys as $key) {
        register_post_meta('post', $key, [
            'type'          => 'string',
            'single'        => true,
            'show_in_rest'  => true,
            'auth_callback' => function ($allowed, $meta_key, $post_id) {
                // Same gate the editor screen uses: whoever may edit the post
                // may set its SEO fields.
                return current_user_can('edit_post', $post_id);
            },
        ]);
    }
});

// Keep the ownership marker out of PUBLIC (view-context) REST responses. We
// only ever read it back in edit context (authenticated) to recognise a post
// we created; exposing it to anonymous callers would leak our internal post
// id and baas provenance on the customer's own public site.
add_filter('rest_prepare_post', function ($response, $post, $request) {
    if (($request['context'] ?? 'view') !== 'edit') {
        $data = $response->get_data();
        if (isset($data['meta']['_baas_post_id'])) {
            unset($data['meta']['_baas_post_id']);
            $response->set_data($data);
        }
    }
    return $response;
}, 10, 3);
```

Everything else — title, slug, content, excerpt, publish date, status —
works on stock WordPress with no plugin at all.

:::callout{severity=info title="We never override the canonical URL"}
Once a post lives on your WordPress, that is where readers and crawlers
should find it, so we let Yoast and Rank Math self-canonicalize. If you
*also* embed the same blog on your site with the [JS SDK](/docs/install-sdk),
pick one of the two as canonical yourself — two identical articles at two
URLs is the one thing search engines dislike here.
:::

## Pull with the plugin instead

If you would rather not hand us an application password, install the
**DailySmith Sync** plugin and let WordPress pull. It polls the
[sync feed](/docs/delivery-api) hourly with your site's public key, creates
or updates posts with the same content, SEO fields, featured image and styling
the push sends, and never overwrites a post it did not create. It also
registers the SEO fields and the ownership marker above, so with it installed
you do not need the bridge snippet, and a site running both channels ends up
with one WordPress post per article.

The plugin is open source (MIT) and lives on GitHub:
[TechnicalInsideTI/dailysmith-wordpress-plugin](https://github.com/TechnicalInsideTI/dailysmith-wordpress-plugin).
Download the zip from the latest release, upload it under **Plugins → Add
New**, then open **Settings → DailySmith Sync** and paste your `pk_…` key.

## Troubleshooting

| What you see | What it means |
|---|---|
| `401 (incorrect_password)` | The application password is wrong or was revoked. Generate a new one — the old value is unrecoverable. |
| `401` with a correct password | Your server is stripping the `Authorization` header. On Apache, add `RewriteRule .* - [E=REMOTE_USER:%{HTTP:Authorization}]` right after `RewriteEngine On` in `.htaccess`. |
| `403 (rest_cannot_create)` | The user authenticated but is not allowed to create posts. Use an editor or administrator. |
| `404` on the REST route | Pretty permalinks are off, or a security plugin has disabled the REST API. Set **Settings → Permalinks** to anything other than "Plain". |
| "returned something that is not JSON" | The URL is not the site root — use `https://example.com`, not `https://example.com/wp-admin`. |
| "Application passwords are not available" | WordPress only offers them over **HTTPS**. Put the site behind TLS. |
| Styling missing on WordPress | The connected user lacks `unfiltered_html` — see **What survives the push** above. |

## Security notes

- The application password is stored **encrypted at rest** (AES-256-GCM,
  with a fresh nonce per value and the ciphertext bound to the blog it
  belongs to). It is never returned by any API response, never logged, and
  never rendered in the dashboard.
- An application password is **not** your WordPress login. Revoking it in
  **Users → Profile** cuts us off immediately and changes nothing else.
- Give it the least role that can publish. Editor is usually right;
  administrator is only needed if you also want plugin-level SEO fields
  set by a user who can install the bridge snippet.
- We require `https://` for the site URL. An application password sent over
  plain HTTP is readable by anything on the path.

## Self-hosting note

Operators running their own baas deployment must set
`BAAS_ENCRYPTION_KEY` to 32 random bytes, base64 encoded
(`openssl rand -base64 32`); without it the API refuses to store WordPress
credentials at all. To rotate: move the current value into
`BAAS_ENCRYPTION_KEYS_OLD` (comma-separated, decrypt-only), put a fresh key
in `BAAS_ENCRYPTION_KEY`, and restart. Stored credentials keep working
because the retired key still decrypts them — but they are **not**
re-encrypted automatically. To finish retiring the old key, re-save each
WordPress connection (that rewrites it under the new key), then drop the
retired key from `BAAS_ENCRYPTION_KEYS_OLD`.
