# WordPress plugin

Source: https://dabadata.com/docs/wordpress/ (HTML) - https://dabadata.com/docs/wordpress/index.md (this page as markdown)

The dabadata plugin puts live data on WordPress pages. Connect the site to a project in two clicks, map WordPress roles to the project's roles, and add the **Live table** block: everyone sees rows change as they happen, and logged-in visitors add and edit them under the project's rules. The plugin also mirrors the site's own content changes into dabadata.

## Quick start

1. [Create a dabadata account](https://dabadata.com/console/auth/register) (free).
2. [Download the plugin](https://dabadata.com/wordpress/dabadata-0.2.1.zip). In WordPress: **Plugins > Add New > Upload Plugin**, choose the zip, **Install Now**, **Activate**.
3. **Settings > dabadata > Connect to dabadata**. Sign in, choose **New project from the WordPress template**, press **Connect**. You are back in WordPress, connected.
4. Edit a page, add the **Live table** block, publish. Logged-in editors add and change notes; everyone sees every change live.

Who may do what comes from the project's rules and your role mapping ([Roles](https://dabadata.com/docs/wordpress/index.md#roles)). The rest of this page explains each step in detail.

## Install

[Download the plugin zip](https://dabadata.com/wordpress/dabadata-0.2.1.zip) and upload it in **Plugins > Add New > Upload Plugin**, then activate it. It needs WordPress 6.5 and PHP 8.1 or newer (with the sodium extension, which PHP ships), and the WordPress server must be able to call `https://dabadata.com`. The site itself does not have to be public: a local WordPress works.

The plugin is not in the WordPress.org directory yet, so WordPress does not update it by itself: to update, download the new zip and upload it again (WordPress offers to replace the installed version). Your connection and settings stay.

From the repository, `plugin/build-zip.sh` builds the same zip into `plugin/dist/`. For local development you can instead symlink `plugin/dabadata` into `wp-content/plugins/`, which is what `site/setup.sh` does for the local test site.

## Connect

In **Settings > dabadata**, check the server address (`https://dabadata.com`, or your own) and press **Connect to dabadata**:

1. The browser opens the console at `/console/connect?site=<site>&return=<admin url>&state=<random>`. Signed out, you sign in or create an account first and land back on it.
2. The console asks *Connect https://your-site to <project>?* Pick a workspace, a project (or **New project from the WordPress template**) and the environment (live by default). The page shows the host the browser goes back to; when it differs from the site's host you confirm it with a checkbox.
3. **Connect** makes a one-time code (`POST /v1/connect/codes`) and sends the browser back to WordPress with `dabadata_code` and the same `state`.
4. WordPress checks the state, generates a 48-character site secret, and exchanges the code server to server (`POST /v1/connect/exchange`). The answer carries the keys; the settings page says *Connected to dabadata project prj_... (live)*.

On the server the exchange stores the site secret as the project secret `WP_SITE_<hash>`, adds the site as an HS256 identity provider (issuer `wp_<hash>`, a new definition version by `wordpress connect`) and mints a secret key for the site, listed on the console's Keys page as **WordPress <host>**. Connecting the same site again replaces its secret and its key.

> **Security:** No key travels in a URL. The code is single use, valid 10 minutes, and only works for the site it was made for. WordPress stores the secret key and the site secret encrypted (libsodium secretbox, with a key derived from `AUTH_KEY` and a random salt); the publishable key and the server address are plain. Only connect sites you run: a connected site holds a secret key of the environment.

**Disconnect** revokes the site's key on the server and removes the keys and the site secret from WordPress.

## The WordPress template

A project made from the `wordpress` template declares the WordPress roles `administrator`, `editor`, `author`, `contributor`, `subscriber` and `visitor`, and one table for the first block:

```json
"notes": {
  "columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false }, "author": { "type": "userId" } },
  "rules": {
    "read": "anyone",
    "create": { "role": ["administrator", "editor", "author"] },
    "update": { "any": [{ "role": ["administrator", "editor"] }, { "owner": "author" }] },
    "delete": { "role": ["administrator", "editor"] }
  }
}
```

Any other project works too: Connect adds a `visitor` role when the project has none (the issuer's default role), and the block shows whichever table you name.

## Identity bridge

WordPress stays the login. For a logged-in user the plugin signs a short-lived JWT with the site secret, and the page's script hands it to the dabadata client, so the user acts in the project under its rules:

| Claim | Value |
| --- | --- |
| `iss` | The site's address as the server stored it, e.g. `https://example.org` |
| `aud` | `dabadata` |
| `sub` | `wp:<user id>` - the id the rules see (`owner` columns hold it) |
| `name` | The display name |
| `role` | The project role mapped from the user's WordPress role |
| `iat`, `exp` | Issued now, valid 10 minutes |

The page gets `{ url, key, token }` and renews the token a minute before it expires through `GET /wp-json/dabadata/v1/token`, which needs the WordPress login cookie and the REST nonce (`X-WP-Nonce`); without them it answers 401. Logged-out visitors get no token: the client is anonymous with the publishable key.

## Roles

**Settings > dabadata > Roles** maps every WordPress role, custom roles included, to one of the project's roles (read from the project's definition). By default a WordPress role maps to the project role of the same name, and anything else to `visitor`. A user with several WordPress roles gets the mapping of the first. A role the project does not declare counts as `visitor` on the server too, so a mapping can never make anyone more than the project allows. Changes apply to each visitor's next token, within 10 minutes.

## Live table block and shortcode

The block **Live table** (`dabadata/live-table`) and the shortcode render the same thing:

```
[dabadata_table table="notes" columns="title,done" editable="1" orderby="created_at desc" limit="50" title="Team notes"]
```

| Attribute | Meaning |
| --- | --- |
| `table` | A table of the connected environment. Default `notes`. |
| `columns` | Comma-separated columns to show. Empty shows every declared column (the block's default is `title,done`). |
| `editable` | Logged-in visitors get an add form and Edit / Delete on each row; a bool column becomes a checkbox they can tick. Default on for the block, off for the shortcode. |
| `orderBy` | A column, optionally followed by `desc` (or prefixed with `-`). Default: oldest first. |
| `limit` | At most this many rows, 1 to 200. Default 50. |
| `title` | A heading above the table. |

On the page the rows arrive over the dabadata socket and every change by anyone shows at once. Rows the visitor owns carry a **you** badge. A write the rules refuse shows the rule's explanation next to the row, for example *Not allowed: update notes: denied, any[0] role "subscriber" not in ["administrator","editor"]; any[1] row.author "wp:7" != user "wp:8"*. An empty table says so; a logged-out visitor on an editable table sees a log-in link instead of the form.

The scripts load only on pages that use the block or the shortcode: the client from the server (`<server>/client/dist/dabadata.iife.js`) and the plugin's small table script. With JavaScript off the page still lists the rows an anonymous visitor may read, fetched server side with the publishable key and cached for a minute.

## Privacy

- **Connecting** sends the site's address and the settings page's address to the console (in the browser), and the one-time code, the site's address and the site secret to the server.
- **Live tables**: visitors' browsers talk to the dabadata server directly - it sees their IP address and browser like any web request. For a logged-in visitor the token carries `wp:<user id>`, the display name and the mapped role. Email addresses and passwords are never sent. Rows written through a block are stored in the project with their author's `wp:<id>`.
- **The server** keeps each call in the project's trace and request log for the plan's `traceDays` and `logDays` (see [Plans and limits](https://dabadata.com/docs/limits/index.md)), visible to the project's workspace members in the console.
- **The mirror** sends only what is listed under [What is sent](https://dabadata.com/docs/wordpress/index.md#what-is-sent).

The plugin's `readme.txt` carries the same disclosure for the plugin directory. Uninstalling the plugin deletes its options and transients.

## Settings

Everything is in the `dabadata_settings` option:

| Setting | Meaning |
| --- | --- |
| Server URL | What PHP calls, e.g. `https://dabadata.com`. It must be reachable from the WordPress server. |
| Browser URL | Optional. The address browsers use for the server (the live blocks and the admin page's viewer) when it differs from the server URL. |
| Publishable key | `pk_live_...` / `pk_test_...`, plain - it is sent to browsers anyway. |
| Secret key | Encrypted. Used server side only: the mirror, the viewer's view token, the project's roles. Never printed into a page. |
| Site secret | Encrypted. Signs the identity bridge's tokens. |
| Roles | WordPress role to project role. |

**Manual setup** (self-hosted servers): paste the server URL, the publishable key (empty for a self-hosted server's default project) and the secret key - or the server's `DABADATA_PROJECT_KEY`. For the identity bridge, **Generate a site secret** and declare it as an issuer in the project file, as the settings page shows:

```json
{ "name": "wordpress", "issuer": "https://example.org", "audience": "dabadata",
  "secretEnv": "WORDPRESS_JWT_SECRET", "roleClaim": "role", "defaultRole": "visitor" }
```

**Export settings** downloads the server, browser URL, publishable key and role mapping as JSON - never the secret key or the site secret. **Import settings** takes that JSON on another site, which then still needs Connect (or the secret key pasted by hand).

## The connect API

| Endpoint | Who | What |
| --- | --- | --- |
| `POST /v1/connect/codes` | Console session, developer or higher, `X-Project` | `{ site, return }` (http(s), no credentials) -> `{ code, site, expiresAt }`. The code is 64 hex characters, single use, 10 minutes, bound to the environment, the site and the return URL. |
| `POST /v1/connect/exchange` | The code in the body (30 per IP per hour) | `{ code, site, siteSecret }` -> `{ project, env, publishableKey, secretKey, site, issuer, roles }`. A used, expired or other site's code answers 400 `code_invalid`. |

## The mirror

Beside the blocks, the plugin streams the site's own content changes to dabadata with the secret key, and the **dabadata** admin menu shows them live.

### What it hooks

| Component | WordPress hooks |
| --- | --- |
| `post:<type>` | `wp_after_insert_post`, `deleted_post` - every post type except internal ones such as revisions, menu items and changesets. |
| post meta | `added_post_meta`, `updated_post_meta`, `deleted_post_meta` (keys not starting with `_`), sent as a change on the post. |
| `user` | `user_register`, `profile_update`, `set_user_role`, `deleted_user` |
| `comment` | `wp_insert_comment`, `edit_comment`, `transition_comment_status`, `deleted_comment` |
| `term:<taxonomy>` | `created_term`, `edited_term`, `delete_term` |
| `wc:order` | `woocommerce_new_order`, `woocommerce_update_order`, `woocommerce_order_status_changed`, `woocommerce_delete_order`, `woocommerce_trash_order` - only when WooCommerce is active. |

Each event says what happened (`add`, `update` or `remove`), to which record, who did it, the record's current fields and, for updates, what changed from what to what. All events of one page load are sent together in one request, when WordPress shuts down, so a save never waits on dabadata.

### What is sent, and what never is

- **Posts:** status, slug, author, dates, a 200-character excerpt and the content length - not the body.
- **Users:** login, display name, roles, registered date.
- **Comments:** post, author name, status, type.
- Every string is cut to 500 characters.

> **Security:** Never sent: email addresses, password hashes, activation keys, comment author emails, IP addresses and full post bodies.

If the server cannot be reached, the site carries on; the failure is written to the PHP error log with a `[dabadata]` prefix.

### The dabadata admin page

The **dabadata** menu, for administrators, shows whether the server is reachable, the **Send snapshot** button, and the dabadata viewer embedded in the page. The plugin mints a view token with the secret key for the viewer and caches it for 11 hours, under the token's 12-hour lifetime. The shortcode `[dabadata_inspect]` puts the same viewer on any page; it renders for administrators only.

In the viewer, the **Mirror** page is the WordPress flow: every event as it arrives and the current state of each record, grouped by component.

### Send snapshot

The plugin only sees changes made after it was activated. **Send snapshot** backfills everything that already exists - every post, user, term and approved comment - as `add` events marked `snapshot: true`, read in batches of 100 and sent in batches of 200. Press it once after installing, and again whenever you want the mirror to match the site exactly.

### The mirror API

The plugin speaks the original (v0) part of the API, which sits beside the v1 database:

| Endpoint | Who | What |
| --- | --- | --- |
| `POST /v1/events` | Secret key | `{ "events": Event[] }`, 1 to 500 per call. Each is stored and applied to the record it names, then broadcast to view-token sockets. Answers `{ "accepted": n }`. |
| `GET /v1/events` | View token | Stored events, newest first. `limit`, `before`, `component`. |
| `GET /v1/components` | View token | Each component with its record count, event count and last activity. |
| `GET /v1/records` | View token | Current records, newest updated first. `component`, `limit`. |

```json
{
  "source": "wordpress",
  "site": "http://localhost:8080",
  "component": "post:page",
  "action": "update",
  "entityId": "42",
  "title": "About us",
  "actor": { "id": "1", "name": "admin", "role": "administrator" },
  "data": { "status": "publish", "slug": "about-us", "author": "1" },
  "changes": { "title": { "from": "About", "to": "About us" } },
  "snapshot": false,
  "ts": 1760000000000
}
```
