# BW Guides Server — Claude Development Guide

**Version:** 0.2.0 | **Last Updated:** 2026-07-24

## What this plugin does

The central publishing server for BW Guides. Rian authors guides here (Gutenberg,
`bw_guide` CPT), targets each guide at all sites or selected sites, and client sites
running the distributable `bw-guides` plugin pull them over an authenticated REST API.
Lives only on the `plugins.bowden.works` dist host.

## Critical notes

- It is NOT distributed to client sites and NOT released via `tools/release.sh` — it's
  site infrastructure, updated in place (same model as bw-update-server).
- **Never touch bw-update-server's files, tables, or its `bw/v1` REST namespace.** The
  two plugins share nothing by design; a bug here must not be able to break plugin
  update serving. After any change, verify `bw/v1/update-check` still responds.
- Never log client-site content or user data — only site metadata (URL, IP, timestamps,
  client plugin version). Tags and notes that clients add never leave their site.
- Never add endpoints that execute code or file operations.
- Never change the `bw-guides/v1` route structure without a coordinated client release.
- **Author guides with core blocks only.** This site has Kadence Blocks; client sites
  don't. Kadence markup renders unstyled/degraded on clients — twice over: the plugin
  isn't there to emit its per-`uniqueID` CSS, and `palette*` / `has-theme-palette-*`
  colours are defined by the *Kadence theme*, which clients also don't have. Shipping
  Kadence's CSS would not fix the second problem.
- **Design with the pattern library, not by hand.** The "BW Guides" category in the
  pattern inserter carries the approved components; they are core blocks styled by
  class name. Do not hand-write inline `style` attributes on blocks — Gutenberg
  validates saved markup against what it would serialize, and a near-miss produces
  "unexpected or invalid content" for the author.
- **Never use theme.json preset colour classes** (`has-*-background-color`). Clients
  read guides in wp-admin, where WordPress never loads global styles, so presets
  render colourless. Colour comes from the design-kit classes.
- **The block allowlist is the enforcement, not a guideline.**
  `BW_Guides_Server_Patterns::restrict_block_types()` limits the `bw_guide` editor to
  24 blocks. It is an allowlist rather than "core only" because `core/embed`,
  `core/shortcode` and `core/html` are ALSO broken on clients — the client renders
  with `do_blocks()` alone, so oEmbed and shortcodes never run and kses strips
  iframes/scripts. If you add a block to that list, first verify it survives
  `wp_kses_post( do_blocks( … ) )` on a client.
- **The allowlist governs insertion, not content.** Pasted block markup and restored
  revisions bypass it, so a guide can still acquire foreign blocks that way. If a
  guide renders oddly on a client, check its markup for non-allowlisted blocks first.
- **Client-group targeting matches are exact-string, not fuzzy.** A site's `client_slug`
  and a guide's `_bw_guides_target_client` are both normalized through
  `BW_Guides_Server_Sites::normalize_client_slug()` (`sanitize_title()`, max 64 chars)
  before storage, specifically so a plain `===` compare at manifest time is safe. Always
  write to those fields through `BW_Guides_Server_Sites::set_client_slug()` /
  `create()` or the admin metabox save — never insert a raw slug directly.
- **The heading-keyword match is a literal string compare, not entity-aware.** The
  save-time mismatch check strips HTML tags but does **not** decode HTML entities —
  `&#8217;` and a literal `’` character are different strings to it. Author the keyword
  line's heading using the exact characters the block editor stored, not a
  hand-typed approximation.

## Architecture

- `bw-guides-server.php` — bootstrap.
- `includes/class-bw-guides-server-installer.php` — dbDelta table on activation.
- `includes/class-bw-guides-server-cpt.php` — `bw_guide` CPT (public=false, show_ui).
- `includes/class-bw-guides-server-sites.php` — sites registry: CRUD, key
  generate/verify (sha256 + hash_equals), sync bookkeeping, client-group slug
  read/write (`set_client_slug()`, `client_slugs()`, `normalize_client_slug()`).
- `includes/class-bw-guides-server-rest.php` — REST routes under `/wp-json/bw-guides/v1/`.
- `includes/class-bw-guides-server-admin.php` — Sites screen (+ per-site Client column
  and inline edit), Targeting metabox (all / selected / specific client), Audience
  column, and the Search keywords & category metabox.
- `includes/class-bw-guides-server-patterns.php` — the design kit: block patterns
  registered for `bw_guide` only, plus the editor stylesheet.
- `uninstall.php` — drops the sites table + options, keeps all `bw_guide` posts.

## REST API (namespace `bw-guides/v1`)

Auth: header `X-BW-Guides-Key: bwg_<site_id>_<40 hex>`, verified in the
`permission_callback` (only `/health` is public). Invalid key → 401
`bw_guides_invalid_key`; revoked → 403 `bw_guides_key_revoked`.

- `GET /health` → `{status, version, time}` (public).
- `GET /manifest?client=<ver>&schema=1` → every published guide visible to the
  authenticated site: `{schema_version, hub_version, generated_at, site:{id,name},
  guides:[{id, slug, title, modified_gmt, content_hash, audience}]}`.
  The manifest is the complete truth for that site — clients trash local copies of
  anything absent from it. `audience` is `all` | `selected` | `client` (informational
  only — the client plugin does not parse it; extending its values is backward
  compatible).
- `GET /guides?include=1,2,3&client=<ver>` → full payload for up to 20 ids; ids not
  visible to the requesting site are silently omitted. Per guide: `{id, slug, title,
  excerpt, content (raw block markup), modified_gmt, content_hash,
  meta:{min_wp_version, requires_plugins, tokens, category, section_keywords}}`.
  `min_wp_version`/`requires_plugins`/`tokens` still ship empty in schema 1 (deferred
  features). `category` and `section_keywords` are populated from the guide's
  "Search keywords & category" metabox (0.2.0+) — **the payload meta contract**:
  - `meta.category` — string. `''` if unset.
  - `meta.section_keywords` — array of `{"heading": "<exact heading text>",
    "keywords": "kw, kw"}`. `[]` if unset. A row with `heading === "*"` carries
    guide-level keywords not tied to any section. `heading` is matched by the client
    against its own parsed headings **case-insensitively and trimmed, exact-string**
    otherwise (no fuzzy matching, no HTML-entity decoding) — it must read the same
    way the hub's mismatch-check compares it (see the CRITICAL notes above).
  - Both are additive to the existing `meta` object — schema_version stays 1.

## Database

Table `{prefix}bw_guides_sites` — the sites registry. Keys are stored as
`hash('sha256', key)` only; the raw key is shown exactly once (via a 120s transient)
when created/regenerated. sha256 (not password_hash) is deliberate: 160-bit CSPRNG
tokens don't need slow hashing, and deterministic hashing gives O(1) verified lookup.
`client_slug VARCHAR(64) NOT NULL DEFAULT ''` (DB version 2, added 0.2.0) is the
site's client-group membership, normalized via `sanitize_title()` on write.

## Guide targeting

Post meta on `bw_guide`: `_bw_guides_audience` (`all`|`selected`|`client`),
`_bw_guides_target_sites` (array of site-registry ids, used when audience=`selected`),
and `_bw_guides_target_client` (a normalized client slug, used when audience=`client`).
Only `publish`-status guides are ever served.

**Client-group resolution** (`BW_Guides_Server_REST::visible_guides()`): a guide with
`audience === 'client'` is visible to a site when the site's `client_slug` is non-empty
**and** equals the guide's `_bw_guides_target_client` exactly (both normalized the same
way at write time, so this is a plain `===`). This means registering a new site under an
existing client slug immediately back-fills every guide already targeted at that client
— no per-guide re-targeting needed. That is the entire point of client-group targeting
over per-site "selected" targeting.

Guide-authoring metadata (not used for targeting): `_bw_guides_category` (string) and
`_bw_guides_section_keywords` (array of `{heading, keywords}`) — see the payload
contract under REST API above. Set via the "Search keywords & category" metabox on the
guide editor (`BW_Guides_Server_Admin::render_keywords_metabox()` /
`save_keywords()`).

**⚠ wp-cli meta edits don't bump `post_modified_gmt`.** Editing `_bw_guides_category`,
`_bw_guides_section_keywords`, `_bw_guides_audience`, or any other guide post meta via
`wp eval`/`update_post_meta()` directly (rather than through a metabox save, which goes
through `wp_update_post()`) leaves `post_modified_gmt` untouched. Clients diff
`modified_gmt` in the manifest to decide whether to re-pull a guide, so a wp-cli-only
meta edit is invisible to them until something else touches the post. If you must edit
meta via wp-cli, follow it with a no-op touch, e.g.
`wp post update <ID> --post_title="$(wp post get <ID> --field=post_title --format=raw)"`
(or any `wp_update_post( array( 'ID' => <ID> ) )` call) to bump the timestamp.

## Testing locally

```bash
curl https://plugins.bowden.works/wp-json/bw-guides/v1/health
curl -H "X-BW-Guides-Key: bwg_1_<hex>" https://plugins.bowden.works/wp-json/bw-guides/v1/manifest
curl -H "X-BW-Guides-Key: bwg_1_<hex>" "https://plugins.bowden.works/wp-json/bw-guides/v1/guides?include=42"
# And always, after changes:
curl "https://plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-map-magnet"
```

## Version bumps

Updated in-place on the dist host:
1. Bump `Version:` header + `BW_GUIDES_SERVER_VERSION` + this file's header.
2. Update `CHANGELOG.md`.
3. If the DB schema changed, bump `BW_GUIDES_SERVER_DB_VERSION` (maybe_upgrade runs
   dbDelta on next load) — no deactivate/reactivate needed.
4. Verify both `bw-guides/v1/health` AND `bw/v1/update-check` respond.
