This document defines the next iteration of the pwb-properties plugin: a write-capable
admin integration that allows editors to create and update property listings in PWB from
inside EmDash.
For embedding PWB properties inside EmDash posts/pages via Portable Text blocks, see
docs/pwb-properties-content-embedding.md.
This is not a loose concept note. It is intended to be concrete enough to implement.
The existing pwb-properties plugin is a read-only adapter:
- EmDash hosts the admin UI
- PWB remains the source of truth for listings
- the plugin reads property data over the PWB API
The write-capable version should keep that same core model:
- do not move listings into EmDash collections
- do not duplicate search logic inside EmDash
- do not sync two different canonical stores
Instead, the plugin becomes a structured admin-facing proxy for PWB write operations.
The recommended implementation is:
- keep
pwb-propertiesas the plugin ID and package - convert it from a narrow read-only Block Kit browser into a richer admin editing tool
- use native/trusted plugin UI with React admin pages for editing
- keep the backend route layer in the plugin so all write logic, validation, auth, and logging stay centralized
The frontend site still reads listings directly from PWB. The plugin only improves the editor experience.
There are two broad options for editable listings:
- make EmDash the listing backend
- keep PWB as canonical and edit PWB through the plugin
This project already assumes:
- PWB owns listing schema and listing persistence
- PWB owns listing search and filtering behavior
- PWB owns listing detail content and enquiry flows
- EmDash owns pages, posts, menus, and site-managed content
Because of that, option 2 is the only pragmatic next step.
If EmDash became the listing backend, the project would need:
- new collections for properties and related entities
- new search/filter APIs
- new public rendering contracts
- migration and sync strategy from PWB
- media handling for listing galleries
- a plan to retire or heavily minimize PWB
That is a platform rewrite, not a plugin increment.
Allow an authenticated EmDash admin user to:
- browse properties
- open a property edit view
- change a defined subset of fields
- save those changes to PWB
- see clear validation and save feedback
Later phases may add:
- create property
- publish/unpublish
- draft status
- media upload
- localized fields
- bulk actions
But the first implementation should not attempt all of those at once.
For the write-capable version, the recommended format is native/trusted plugin with React admin pages.
Why:
- property editing needs larger forms than Block Kit handles comfortably
- editors will need field-level validation and likely grouped sections
- image handling, conditional fields, and async save states are much easier in React
- there may be future needs for autosave, dirty-form warnings, and optimistic refresh
That means:
- the plugin stays registered from
astro.config.mjs - it runs trusted in dev and production
- it should not rely on marketplace-style sandbox installation
If you explicitly want marketplace compatibility later, treat that as a separate product goal. For now, prioritize the UX and implementation quality needed for listing editing.
EmDash Admin React Page
│
│ usePluginAPI()
▼
Plugin Route Layer
│
├── Validate EmDash user permissions
├── Read plugin settings (URL, auth mode, etc.)
├── Transform admin form input -> PWB payload
├── Call PWB write API
├── Normalize PWB validation errors
└── Log request / response / failures
▼
PWB Rails API
│
├── create property
├── update property
├── publish / unpublish
├── upload media (later)
└── return canonical property JSON
Critical rule:
- the plugin never writes listing data into EmDash content tables
The plugin may store only:
- connection settings
- auth settings
- draft UI state if necessary
- audit/diagnostic metadata if useful
- rendering forms
- collecting editor input
- displaying field errors and save status
- fetching latest property data for editing
- warning on unsaved changes
- authorizing access
- validating settings exist
- validating request shape
- transforming between UI model and PWB API model
- performing authenticated PWB calls
- normalizing PWB error responses
- logging
- persistence
- domain validation
- canonical property state
- any business rules around publishability, completeness, permissions, or media semantics
Recommended target structure:
packages/plugins/pwb-properties/
├── package.json
├── tsconfig.json
└── src/
├── index.js # descriptor
├── sandbox-entry.js # route layer
├── admin.jsx # React admin entry
├── types.js # plugin-local runtime-safe constants/types
├── settings.js # settings access + validation
├── auth.js # PWB auth header construction
├── api/
│ ├── client.js # plugin-side PWB client wrappers
│ ├── mappers.js # UI <-> PWB field mapping
│ └── errors.js # normalize PWB errors
└── admin/
├── pages/
│ ├── PropertiesPage.jsx
│ ├── PropertyEditPage.jsx
│ └── SettingsPage.jsx
├── components/
│ ├── PropertyListTable.jsx
│ ├── PropertyEditForm.jsx
│ ├── SaveBar.jsx
│ ├── FieldErrorList.jsx
│ └── PwbConnectionBanner.jsx
└── hooks/
├── usePropertyEditor.js
└── usePluginSettings.js
The current read-only code in src/sandbox-entry.js can remain as the seed for:
- connection handling
- route dispatch
- logging conventions
- list/detail fetches
But the admin UI should no longer be limited to Block Kit once editing is added.
The descriptor should evolve from a standard sandbox-oriented descriptor to a native/trusted descriptor that includes a React admin entry.
Conceptually:
export function pwbPropertiesPlugin() {
return {
id: "pwb-properties",
version: "0.2.0",
entrypoint: "pwb-properties",
adminEntry: "pwb-properties/admin",
capabilities: ["network:fetch:any"],
adminPages: [
{ path: "/", label: "Properties", icon: "list" },
{ path: "/settings", label: "Settings", icon: "settings" },
{ path: "/edit", label: "Edit Property", icon: "pencil" }
]
};
}The exact shape should follow the native plugin API the project uses at implementation time.
The important design decision is:
- React admin pages are first-class
- network capability remains required
The write-capable plugin needs a more explicit settings model than the read-only version.
Use plugin KV for settings.
| Key | Type | Purpose |
|---|---|---|
settings:pwbApiUrl |
string | Base URL of the PWB app |
settings:authMode |
string enum | How the plugin authenticates to PWB |
settings:apiToken |
secret string | Token or API key, depending on auth mode |
| Key | Type | Purpose |
|---|---|---|
settings:propertyWritePath |
string | Override for write endpoint prefix if PWB differs by environment |
settings:publicPropertyPathTemplate |
string | Optional public URL template |
settings:locale |
string | Default locale, if not fixed to en |
settings:requestTimeoutMs |
number | Safety timeout for outbound write calls |
settings:enableDebugLogging |
boolean | Temporary verbose logging control |
bearer_tokenbasic_authsession_proxynone
For this project, prefer bearer_token first.
- easier to change without deploy
- consistent with current plugin pattern
- works per environment/site instance
- keeps the admin self-service
- must be absolute
httporhttps - trim trailing slash before storing
- reject empty string
- must be one of the allowed enum values
- default to
bearer_token
- required when
authMode === "bearer_token" - stored as secret-like setting
- never log raw value
The plugin needs two levels of authorization:
- whether the EmDash user may use the editing feature
- how the plugin authenticates to PWB
These are separate concerns and should stay separate.
Only authenticated EmDash users with plugin-management access should be allowed to call write routes.
Minimum enforcement:
- private plugin routes only
- rely on EmDash route auth for admin access
- reject unauthenticated or unauthorized users at the plugin route if user context is available
If plugin route context exposes the EmDash user, enforce:
- admin only for v1
Later you can relax to:
- admin + editor roles for property editing
- admin only for plugin settings
The plugin must authenticate itself to PWB on every write request.
Recommended v1 model:
- create a dedicated PWB API token for the EmDash site
- store that token in plugin settings
- send it as
Authorization: Bearer <token>
Do not use:
- a shared human admin password
- cookie/session scraping from an embedded PWB UI
- a browser-side direct write from the admin page to PWB
All write requests should flow through the plugin route layer.
src/auth.js should expose something like:
export function buildPwbAuthHeaders(settings) {
switch (settings.authMode) {
case "bearer_token":
return { Authorization: `Bearer ${settings.apiToken}` };
case "basic_auth":
return { Authorization: `Basic ${btoa(`${settings.username}:${settings.password}`)}` };
default:
return {};
}
}The route layer merges those headers with:
Accept: application/jsonContent-Type: application/jsonfor write requests
- never log token values
- never send secrets back to the browser
- never return the full settings payload from a settings read route
- redact authorization headers in error logs
The write-capable plugin should expose explicit, narrow routes rather than a single giant
admin catch-all.
Use React admin pages for UI, and use plugin API routes for CRUD operations.
Recommended private routes:
| Route | Method | Purpose |
|---|---|---|
settings/get |
GET | Return safe settings for the admin UI |
settings/save |
POST | Save plugin settings |
properties/list |
GET | Return paginated property list |
properties/get |
GET | Return one property by slug or id |
properties/update |
POST | Update editable fields for a property |
properties/create |
POST | Create a new property shell |
properties/publish |
POST | Publish a property, if PWB supports separate publish |
properties/unpublish |
POST | Unpublish a property |
properties/refresh |
POST | Re-fetch canonical property state after save |
Optional later routes:
| Route | Method | Purpose |
|---|---|---|
properties/media/upload-url |
POST | Upload helper if PWB supports direct upload |
properties/media/attach |
POST | Attach uploaded media to a property |
properties/validate |
POST | Dry-run validation without saving |
properties/history |
GET | Show last plugin-side save attempts or audit entries |
- easier to type and test
- clearer logging
- cleaner error handling
- better React UI integration with
usePluginAPI() - less hidden branching
The existing admin Block Kit route can remain temporarily for read-only browsing during
transition, but the write UI should use explicit data routes.
The shapes below are intentionally concrete.
Purpose:
- provide non-secret settings needed by the admin UI
Response:
{
"pwbApiUrl": "https://example.com",
"authMode": "bearer_token",
"hasApiToken": true,
"locale": "en",
"enableDebugLogging": false
}Do not return the raw token.
Purpose:
- validate and persist plugin settings
Request:
{
"pwbApiUrl": "https://example.com",
"authMode": "bearer_token",
"apiToken": "secret-token-value",
"locale": "en",
"enableDebugLogging": true
}Response:
{
"saved": true,
"hasApiToken": true
}Validation failures should return normalized field errors:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Settings are invalid",
"fields": {
"pwbApiUrl": ["Must be a valid absolute URL"],
"apiToken": ["API token is required"]
}
}
}Purpose:
- populate the property table view
Query params:
pageperPagesaleOrRentalqlater, if search is added
Request example:
GET /_emdash/api/plugins/pwb-properties/properties/list?page=1&perPage=20&saleOrRental=sale
Response:
{
"items": [
{
"id": 123,
"slug": "beautiful-villa-marbella",
"title": "Beautiful Villa Marbella",
"formatted_price": "€1,250,000",
"for_sale": true,
"for_rent": false,
"count_bedrooms": 4,
"count_bathrooms": 3,
"city": "Marbella",
"region": "Malaga",
"country_code": "ES"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 201,
"total_pages": 11
}
}The plugin should normalize PWB search responses into this admin-focused shape even if the raw response is different.
Purpose:
- load one canonical property into the edit form
Query params:
slugorid
Prefer slug in the UI if that is the stable routing key already used by the site.
Response:
{
"property": {
"id": 123,
"slug": "beautiful-villa-marbella",
"title": "Beautiful Villa Marbella",
"description": "<p>...</p>",
"formatted_price": "€1,250,000",
"for_sale": true,
"for_rent": false,
"count_bedrooms": 4,
"count_bathrooms": 3,
"address": "Example street",
"city": "Marbella",
"region": "Malaga",
"country_code": "ES",
"updated_at": "2026-04-05T13:00:00Z"
}
}The plugin may also return a UI-normalized form model:
{
"property": { "...canonical..." : "..." },
"form": {
"title": "Beautiful Villa Marbella",
"description": "<p>...</p>",
"saleOrRental": "sale",
"price": "1250000",
"bedrooms": "4",
"bathrooms": "3",
"address": "Example street",
"city": "Marbella",
"region": "Malaga",
"countryCode": "ES"
}
}That is often easier for the form layer.
Purpose:
- persist the first editable property form
Request:
{
"slug": "beautiful-villa-marbella",
"changes": {
"title": "Beautiful Villa Marbella",
"description": "<p>Updated description</p>",
"saleOrRental": "sale",
"price": "1250000",
"bedrooms": "4",
"bathrooms": "3",
"address": "Example street",
"city": "Marbella",
"region": "Malaga",
"countryCode": "ES"
},
"expectedUpdatedAt": "2026-04-05T13:00:00Z"
}Response on success:
{
"saved": true,
"property": {
"id": 123,
"slug": "beautiful-villa-marbella",
"title": "Beautiful Villa Marbella",
"updated_at": "2026-04-05T14:05:00Z"
}
}Response on field validation error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Property update failed validation",
"fields": {
"title": ["Title is required"],
"price": ["Price must be numeric"]
}
}
}Response on stale write conflict:
{
"error": {
"code": "CONFLICT",
"message": "Property was updated elsewhere. Refresh before saving again."
}
}The plugin should generate these normalized responses even if PWB’s raw error format is different.
This should not be in the first milestone unless PWB’s creation API is already stable and documented. If added, it should create only a minimal property shell:
Request:
{
"title": "New Listing",
"saleOrRental": "sale"
}Response:
{
"created": true,
"property": {
"id": 456,
"slug": "new-listing",
"title": "New Listing"
}
}The plugin should not scatter raw fetch() logic through routes.
Create a plugin-side PWB client that wraps:
- URL construction
- auth header construction
- timeout handling
- JSON parsing
- error normalization
Suggested internal API:
export async function pwbListProperties(ctx, settings, params) {}
export async function pwbGetProperty(ctx, settings, slug) {}
export async function pwbUpdateProperty(ctx, settings, payload) {}
export async function pwbCreateProperty(ctx, settings, payload) {}
export async function pwbPublishProperty(ctx, settings, payload) {}The route layer should call these helpers, not raw ctx.http.fetch().
That gives you:
- consistent logging
- one place to attach auth
- one place to transform PWB errors
The plugin admin form model should not blindly mirror the raw PWB payload.
Use three distinct models:
- PWB canonical model: whatever PWB returns
- Admin form model: normalized and editor-friendly
- Update payload model: shape expected by the PWB write endpoint
PWB canonical:
{
"count_bedrooms": 4,
"count_bathrooms": 3,
"for_sale": true,
"for_rent": false
}Admin form model:
{
"bedrooms": "4",
"bathrooms": "3",
"saleOrRental": "sale"
}PWB update payload:
{
"count_bedrooms": 4,
"count_bathrooms": 3,
"for_sale": true,
"for_rent": false
}This mapping layer is essential because:
- form controls naturally deal in strings
- UI labels differ from backend keys
- future validation rules will belong in one place
The first form should be intentionally narrow.
| UI Field | PWB Source | Notes |
|---|---|---|
title |
title |
required |
description |
description |
rich text or HTML string |
saleOrRental |
for_sale / for_rent |
UI enum mapped to two booleans |
price |
depends on PWB write contract | keep one field in v1 |
bedrooms |
count_bedrooms |
integer |
bathrooms |
count_bathrooms |
integer |
address |
address |
optional |
city |
city |
optional |
region |
region |
optional |
countryCode |
country_code |
optional |
- media gallery
- geolocation
- advanced SEO fields
- internal flags/statuses you have not modeled publicly yet
- multilingual content
- related listings
- features/amenities if they are complex relational data
Recommended sections:
-
Summary
- title
- sale/rental mode
- price
-
Property Details
- bedrooms
- bathrooms
-
Location
- address
- city
- region
- country code
-
Description
- description field
The edit page should show:
- save button
- cancel/reset button
- last-saved timestamp if available
- dirty state warning
- loading spinner during save
- inline field errors
- top-level failure banner for non-field errors
Responsibilities:
- read
slugfrom URL query param - fetch property via
properties/get - initialize editable form state
- track
isDirty - submit to
properties/update - replace local state with canonical response after success
Pseudo flow:
mount
-> GET properties/get?slug=...
-> set initial form state
user edits fields
-> dirty=true
user clicks save
-> POST properties/update
-> if success:
update form baseline
dirty=false
show success toast
if validation error:
map field errors to inputs
if conflict:
show refresh warning
Use:
/_emdash/admin/plugins/pwb-properties/edit?slug=beautiful-villa-marbella
That avoids needing dynamic filesystem routes inside the plugin admin entry.
Validation should happen in two places.
For immediate editor feedback:
- required title
- numeric bedrooms/bathrooms
- valid sale/rental selection
- numeric price format
This should prevent obviously invalid submissions.
Before calling PWB:
- verify required settings exist
- verify request shape
- normalize and coerce numeric fields
- reject impossible enum values
PWB remains the final authority.
The plugin must treat PWB validation errors as canonical and surface them cleanly.
A write-capable editor needs a basic strategy for stale data.
Recommended v1 approach:
- include
expectedUpdatedAton update requests - if the currently loaded property timestamp differs from the editor’s base timestamp, reject with a conflict
If PWB supports optimistic locking natively, use that.
If not, the plugin can still perform a best-effort preflight:
- fetch current property
- compare
updated_at - reject if changed
- otherwise proceed with update
This is not perfect, but it is better than silent overwrite.
Conflict UX:
- show banner: “This property changed elsewhere. Refresh before saving again.”
- offer reload action
The current plugin already uses prefixed logs. The write-capable version should log all of the following at structured info/warn/error levels.
- route name
- property slug or id
- authenticated EmDash user id if available
- whether required settings were present
- outbound PWB endpoint
- response status
- normalized result type: success, validation_error, conflict, upstream_error
Never log:
- API token
- raw authorization header
- full property description payload if that is too noisy
- personally sensitive data if listings eventually contain owner/contact information
pwb-properties: received update request { slug: "villa-1", userId: "..." }
pwb-properties: validated update payload { slug: "villa-1", fields: ["title","price","bedrooms"] }
pwb-properties: calling PWB update endpoint { url: "https://..." }
pwb-properties: PWB update succeeded { slug: "villa-1", status: 200 }
PWB may not return errors in the exact shape your React admin wants.
Create a normalization layer with three output categories:
- field validation errors
- top-level business-rule errors
- upstream/network/system errors
Recommended normalized error schema:
{
"code": "VALIDATION_ERROR",
"message": "Property update failed validation",
"fields": {
"title": ["Title is required"]
}
}or
{
"code": "UPSTREAM_ERROR",
"message": "PWB API request failed"
}The admin page should not parse raw PWB error payloads directly.
Do not include media editing in the first write milestone unless PWB’s upload API is already stable and easy to consume.
Why media is hard:
- upload flows often need multipart or presigned URLs
- image ordering and deletion semantics matter
- thumbnail/variant generation can be asynchronous
- error handling is more complex than plain JSON field updates
Recommended plan:
- v1: text and numeric editing only
- v2: add media read visibility in the editor
- v3: add upload/attach/reorder/delete if PWB API makes that practical
The settings page should now include more than just the base URL.
Recommended fields:
PWB API URLAuthentication ModeAPI TokenDefault LocaleEnable Debug Logging
Settings UX:
- show whether the token is configured without showing its value
- include a “Test Connection” button
- display the result of calling a lightweight authenticated PWB endpoint
This route is optional but highly recommended.
Response:
{
"ok": true,
"message": "Connected successfully"
}Or:
{
"ok": false,
"message": "Authentication failed"
}This will save time during setup and avoid debugging property-save failures that are really connection issues.
The write-capable version should have explicit test coverage across three layers.
For:
- settings validation
- auth header generation
- PWB error normalization
- form model to PWB payload mapping
- route input validation
For plugin routes:
settings/savesettings/test-connectionproperties/getproperties/update
These should mock PWB responses and verify:
- correct outbound path
- correct auth headers
- normalized success/error outputs
For:
- opening the edit page
- changing one field
- saving successfully
- seeing a validation error
- seeing a conflict
If automated E2E is too heavy initially, at least document a manual checklist.
The write-capable plugin should not be considered complete until all of the following pass:
| Check | Expected |
|---|---|
| Settings can save API URL and token | Yes |
| Test connection succeeds with valid auth | Yes |
| Property edit page loads existing property data | Yes |
| Title change saves successfully | Yes |
| Invalid price shows validation error | Yes |
| Save does not leak secrets into logs | Yes |
| PWB write failure shows user-facing error | Yes |
| Stale update produces conflict warning | Yes |
| Reload after save shows canonical updated property | Yes |
- add settings for URL, auth mode, token
- add connection test route
- add plugin-side PWB client wrappers
- add error normalization
- add React admin entry
- add
PropertyEditPage - implement
properties/get - implement
properties/update - support the v1 editable field subset
- add dirty-state warnings
- add conflict handling
- add better save status feedback
- add structured diagnostics
- add
properties/create - add post-create redirect to edit page
- publish/unpublish
- media operations
- richer status management
The safest migration path is incremental.
Keep the current list and settings pages working.
Add a React admin entry and an edit page without removing the current read-only routes.
Add Edit action links from the properties list to the new React page.
Once the React pages are stable, decide whether to retire the old Block Kit detail page or keep it as a lightweight fallback.
This avoids a big-bang rewrite.
Do not:
- store editable property data in EmDash content as a mirror
- send browser-side write requests directly to PWB
- rely on user browser sessions against PWB for auth
- start with media uploads before text/numeric editing is stable
- mix raw PWB payloads directly into form components without mapping
- skip conflict handling entirely
- log secrets
Build the write-capable version as a trusted/native admin plugin that proxies authenticated write requests to PWB, starting with a single edit form for core listing fields.
That gives you:
- a coherent ownership model
- a good editor UX
- no duplicate listing database
- a path to richer CRUD later without rewriting the public site
If the project continues to treat PWB as the listing backend, this is the right next architecture.