Μετάβαση στο περιεχόμενο
ErmisAI

WordPress connector handshake

The OAuth handshake, what the connector writes, plan gating, and the failure modes.

ErmisAI ships the server half of a WordPress integration: an OAuth-style browser handshake that ends with a WordPress site holding a managed tenant API key, plus the pull endpoint that key authenticates against.

There is no PHP plugin in this repository and no download link anywhere in the product. The approval screen and the integrations card both tell the user to "Install the ErmisAI Newsroom Connector in WordPress", with no way to obtain it. docs/ai-newsroom-product-spec.md:808-812 states it directly: "there is currently no PHP plugin source checked into the repository … The plugin is the external client of this flow and must be built/published separately." Earlier docs citing wordpress/hermesai-newsroom-plugin/ or wordpress/ermisai-newsroom-plugin describe paths that do not exist (docs/implementation/spec-alignment.md:94-97).

ErmisAI never writes to WordPress. The data flow is a pull, and the product's own copy says so — app.pages.wordpressConnect.notes.noAutoPublish: "ErmisAI will not publish into WordPress by itself. The plugin only pulls published ErmisAI stories and creates or updates WordPress posts." Post creation, post status, dedupe, and category mapping are entirely the connector's business.

Route inventory

RouteAuthPlan gatePublic in proxy
GET /app/integrations/wordpress/connectClerk sessioncanAccessWordPressForPlan (plan only)no
POST /api/integrations/wordpress/connect/authorizeClerk session + integrations surfacecanAccessWordPressForSubscriptionno
POST /api/integrations/wordpress/connect/exchangeone-time codecanAccessWordPressForSubscriptionyes
GET/DELETE /api/integrations/wordpress/connectionsClerk session + integrations surfacecanAccessWordPressForSubscriptionno
POST /api/integrations/wordpress/disconnectraw key in x-ermis-api-keynoneyes
GET /api/integrations/wordpress/storiesraw key in x-ermis-api-keycanAccessWordPressForSubscriptionyes

The three sessionless routes are listed in the proxy allowlist at src/proxy.ts:161-163 so Clerk does not intercept them; each authenticates itself inside the handler. All three are IP rate-limited under the integration:m2m key — 60 requests / 60 s (src/lib/api/rate-limit.ts:55).

Only wordpress/stories and wordpress/disconnect appear in the public OpenAPI document (src/lib/openapi/public-integration-api.ts). The connect, authorize, exchange, and connections routes are deliberately absent — they are the handshake, not the published API. See Public integration API for the pull contract.

The handshake

The connector redirects a browser to the approval page

/app/integrations/wordpress/connect?siteUrl=…&returnUrl=…&state=…&pluginVersion=…&wordpressVersion=…&siteName=…

There is no ErmisAI-side button that starts this. The flow begins in WordPress; the approval page has no inbound link from the tenant app and its metadata is noindex (src/app/(tenant)/app/integrations/wordpress/connect/page.tsx:23-26).

An unauthenticated visitor is redirected to /sign-in?redirect_url=<the connect URL with its full query> (page.tsx:71-76), so the query survives sign-in.

ErmisAI validates the request and renders the approval screen

Parsing is parseWordPressConnectRequest (src/lib/integrations/wordpress-connect.ts:105-139). The screen shows five fields — "WordPress site", "ErmisAI workspace", "Connector version", "WordPress version", "Return URL" — the two notes, and two actions: "Connect site" and "Cancel".

No WordPress user identity is captured, and there is nothing to save.

The owner presses "Connect site"

The button submits an HTML form (not JSON) to POST /api/integrations/wordpress/connect/authorize; the handler reads Object.fromEntries(await request.formData()) (.../connect/authorize/route.ts:61). It re-runs the same validation, checks the Clerk session, the integrations tenant surface, and the standing-aware plan gate, then creates a connect session and answers 303 to returnUrl with ermisai_status=approved&code=<code>&state=<state> (authorize/route.ts:110-117).

The connector exchanges the code for a key

POST /api/integrations/wordpress/connect/exchange with {"code": …, "state": …, "siteUrl": …}. The session is consumed (single use), the plan gate is re-checked, a managed API key is minted, and the connection row is written. The response body is the only time the raw key is ever returned (exchange/route.ts:94-103).

The connector pulls stories with that key

GET /api/integrations/wordpress/stories with header x-ermis-api-key: <key>. Contract, query params, caveats: Public integration API.

Connect URL contract

ParamRequiredConstraint
siteUrlyesAbsolute http/https URL, no embedded credentials. HTTPS required in production unless the host is loopback. Only the origin is retained — path and query are discarded (wordpress-connect.ts:130).
returnUrlyesSame constraints, plus: origin must equal siteUrl's origin, and the pathname must start with /wp-admin/ (wordpress-connect.ts:117-127). Retained in full.
stateyesTrimmed, 16-200 characters (wordpress-connect.ts:5). Echoed back on every outcome.
siteNamenoTrimmed, max 120 characters; anything failing that becomes '' (wordpress-connect.ts:131-133).
pluginVersionnoTrimmed, max 40 characters; empty becomes null.
wordpressVersionnoTrimmed, max 40 characters; empty becomes null.

Hosts treated as loopback: localhost, *.localhost, 127.*, ::1 (wordpress-connect.ts:21-36). They are the only exemption from the production HTTPS rule, so a plain-HTTP http://localhost site passes URL validation even when NODE_ENV=production. Every other host must be HTTPS there.

Return-URL outcomes

Every outcome routes back through buildWordPressConnectReturnUrl (wordpress-connect.ts:159-175), which sets or deletes query params on the connector's returnUrl.

OutcomeParams added
Approvedermisai_status=approved, code, state
Cancelled (the "Cancel" link)ermisai_status=cancelled, ermisai_message=Connection cancelled., state
Error after the request parsedermisai_status=error, ermisai_code, ermisai_message, state

Error codes carried in ermisai_code (authorize/route.ts:22-46):

CodeMessage sent backCause
unauthorizedAuthentication required.No Clerk session
forbiddenYou do not have permission to authorize WordPress.Not the workspace owner
wordpress_integration_unavailableWordPress integration requires the Pro or Enterprise plan.Plan or standing gate
invalid_wordpress_connect_requestInvalid WordPress connection request.Schema, origin, or /wp-admin/ check failed
wordpress_connect_authorize_failedFailed to authorize WordPress connection.Anything else, including URL-shape failures that throw invalid_wordpress_connect_url

If the form body itself fails to parse there is no returnUrl to redirect to, so the route answers with a JSON error envelope instead (authorize/route.ts:136).

Connect sessions

createWordPressConnectSession (src/lib/platform/integrations/keys-and-wordpress.ts:704-743):

  • code = randomBytes(24).toString('hex') — 48 hex characters.
  • TTL = max(5, ttlMinutes ?? 15) minutes. No caller passes ttlMinutes, so 15 minutes in practice.
  • The record holds ownerUserId, tenantScopeId, siteUrl, siteName, publicationName, pluginVersion, wordpressVersion, returnUrl, state, createdAt, expiresAt.
  • Storage is a single global platform_state array, key platform:integrations-wordpress-connect-sessions:global (src/lib/platform/shared/runtime-state.ts:30-32). There is no Postgres table for it. Expired entries are pruned on every write.

That key is platform-wide, not per-tenant, and every create is a read-modify-write of the whole array. It is a hot key by construction. Keep an eye on it if connect volume ever grows.

consumeWordPressConnectSession (keys-and-wordpress.ts:745-778) matches on code and state and siteUrl (origin-normalised), removes the matched record, and returns it. A second call with the same code returns null and the route answers 410 "WordPress connect session is missing or expired.", code wordpress_connect_session_missing (exchange/route.ts:47-51).

The exchange payload schema (wordpress-connect.ts:83-87) requires code 24-200 characters and state 16-200 characters, and normalises siteUrl to its origin before comparison.

What the exchange returns

Ιδιότητα

Τύπος

Key label construction is buildWordPressManagedKeyLabel (wordpress-connect.ts:177-189); publication-name resolution is resolveWordPressConnectPublicationName (wordpress-connect.ts:92-103).

What gets written

One row in tenant_wordpress_connections (src/lib/db/schema.ts:716-748), with a platform_state fallback under platform:integrations-wordpress-connections:<tenantScopeId>:

ColumnNote
idUUID
tenant_scope_idorg:<clerkOrgId> or user:<clerkUserId>
owner_user_idThe Clerk user who approved
site_urlOrigin. Unique per (tenant_scope_id, site_url)
site_name, publication_nameDefault ''
key_id, key_labelkey_id carries a unique index — one connection per key
plugin_version, wordpress_versionNullable
connected_at, last_used_at, created_at, updated_atlast_used_at is null until the key is first used

Plus one row in tenant_integration_api_keys for the minted key. last_used_at on both is stamped by resolveApiKeyAccess on every successful key resolution (keys-and-wordpress.ts:466-535), which is what the console renders as "Last activity".

If registerWordPressConnection throws, the just-minted key is revoked before the error propagates, so a transient failure cannot leave a live credential with no connection record (exchange/route.ts:74-92).

Key ↔ connection coupling

The two lifecycles are welded together on purpose (module docstring, keys-and-wordpress.ts:1-5):

  • Revoking a key deletes its connection. revokeApiKey (:397), revokeApiKeyByRawKey (:537), and revokeTenantApiKeyByIdInternal (:216) all call deleteWordPressConnectionByKeyId.
  • Disconnecting a connection revokes its key. disconnectWordPressConnection (:674-702) revokes first, then deletes the row if no key was found to revoke.
  • Reconnecting the same site replaces the key silently. registerWordPressConnection (:616-658) looks up the existing connection by siteUrl; if the incoming keyId differs it revokes the old key with notify: false and upserts over the same connection row. The user gets no "API key revoked" notification for that path.

Revoking an API key from the API Keys card takes the WordPress site connected with it offline, and nothing in that card says so. The reverse also holds: "Revoke access" on a WordPress connection revokes the key, so any other consumer using that same key stops working too.

Gating

Role. The integrations tenant surface maps to canManageIntegrations, which is hasAdminBypass(appRole) || tenantRole === 'owner' (src/lib/auth/newsroom-roles.ts:103-105, :200-218), and hasAdminBypass is only appRole === 'super_admin' (:51-53). Only the workspace owner — or a platform super_admin — can approve a connection or manage connections.

The denial string on the approval screen is wrong. app.pages.wordpressConnect.accessRestricted.description reads "Only owners and admins can connect WordPress sites." Tenant admin cannot; the gate is owner-only. Do not repeat that sentence in your own connector copy.

Plan. wordpressDelivery is a capability of individual_pro, business_pro, enterprise, and the hidden internal_unlimited staff plan. Free, Plus, and Business Plus do not have it.

Standing. canAccessWordPressForSubscription runs the plan through resolveEffectivePlanIdForStanding (src/lib/billing/catalog.ts:476-503), which collapses a past_due or canceled subscription to individual_free. Every WordPress API call therefore fails for a Pro workspace that has stopped paying.

The approval page uses the plan-only helper canAccessWordPressForPlan(subscription.plan) (connect/page.tsx:101), while authorize, exchange, and connections use the standing-aware canAccessWordPressForSubscription. A past_due Pro workspace sees the approval screen, presses "Connect site", and is bounced back to wp-admin with ermisai_code=wordpress_integration_unavailable. Handle that redirect explicitly in the connector.

A downgrade does not disconnect anything. The connection row and its key stay in place; only the calls 403. And because DELETE /api/integrations/wordpress/connections is itself behind the standing-aware gate, a downgraded tenant cannot revoke its own connection from the console — the plugin-side disconnect route, which has no plan gate, is the only route still open to it.

Disconnect: two different routes

DELETE /api/integrations/wordpress/connections — the owner-facing action behind the "Revoke access" button. Session-authed, plan-gated, body {"connectionId": "…"}. An unknown id returns 404 "WordPress connection not found". Success returns the remaining connections (connections/route.ts:39-78).

POST /api/integrations/wordpress/disconnect — the connector's self-disconnect. Sessionless, no role gate, no plan gate. Auth is the raw key in x-ermis-api-key; an empty or absent header returns 401 "Tenant API key is required" with code wordpress_disconnect_missing_key. Otherwise it always returns 200 {"ok": true, "revoked": <boolean>} — an unknown or already-revoked key yields revoked: false, so the call is idempotent (disconnect/route.ts:21-49).

The OpenAPI document shows a 500 example carrying code: "wordpress_disconnect_failed" (src/lib/openapi/public-integration-api.ts:307-318). The route builds that response with createInternalRouteErrorResponse, which emits only error and message and no code field (src/lib/utils/error-format.ts:313-328). Do not branch on that code.

The connections console

/app/settings?section=integrations"WordPress connections". Per connection it renders the site name or URL, the raw URL in mono, then "Workspace", "Key", "Connected", "Last activity" ("never" until first key use), and version chips prefixed "Connector" and "WP" (src/components/features/integrations/WordPressConnectionsCard.tsx:58-111). The only action is "Revoke access" / "Revoking...". Empty state: "No WordPress sites connected yet."

GET /api/integrations/wordpress/connections returns { items: [...] } with id, siteUrl, siteName, publicationName, keyId, keyLabel, pluginVersion, wordpressVersion, connectedAt, lastUsedAt, updatedAt (src/lib/api/integrations.ts:13-25). No secret material is exposed.

When the plan does not allow WordPress, the card is replaced by an alert with hardcoded English, not i18n: "WordPress delivery is unavailable" / "This workspace plan does not include managed WordPress connections." (src/components/features/integrations/IntegrationsWorkspace.tsx:314-318).

Failure modes worth knowing

  1. Onboarding blocks the handshake mid-flight. The approval page calls requireCompletedNewsroomSetup (connect/page.tsx:81), which redirects to /app/onboarding and loses the connect query string. A user who signs up from the plugin redirect has to finish onboarding and then restart the flow from WordPress.

  2. APP_URL is load-bearing for the exchange, and it hard-fails. resolveAppOrigin is called while building the exchange response (exchange/route.ts:95). With neither APP_URL nor NEXT_PUBLIC_APP_URL set, the production branch throws a RouteError 503 missing_app_origin (src/lib/auth/clerk-session.ts:150-167) — after the session has been consumed, the key minted, and the connection registered. The connector never receives the key and must restart the handshake; the orphan key is cleaned up by the reconnect, which revokes the previous key for the same site. The launch-readiness entry for APP_URL (src/lib/platform/launch-readiness.ts:90-96) describes this as callbacks resolving to the deployment origin; in production it is a 503, not a fallback.

  3. The code is single-use, including on a retry. If the connector's exchange request times out after ErmisAI has consumed the session, retrying with the same code returns 410. The connector must treat 410 as "start over", not "retry".

  4. state is echoed but never bound to a browser session by ErmisAI. ErmisAI stores it and requires it to match at exchange time; CSRF protection for the WordPress admin side is the connector's responsibility.

  5. Redirects are 303, from a form POST. Anything expecting a JSON response from authorize will break. It is a browser endpoint.

  6. Connect sessions do not survive a platform_state wipe and have no Postgres table. A deployment that clears runtime state mid-handshake invalidates every in-flight code.

  7. A pull is logged as a fetch, not a publication. One delivery-log record per request with method: 'wordpress', not one per story, and the export is subject to the tenant's daily delivery cap — a busy polling loop consumes the newsroom's quota. See Public integration API.

Building a connector

Everything below is the external client's job; none of it exists in this repository.

  • Redirect to the approval page with a fresh state, your siteUrl, and a returnUrl under /wp-admin/ on the same origin.
  • Handle four return states: approved, cancelled, error (read ermisai_code and ermisai_message), and no callback at all.
  • Exchange the code immediately; store apiKey and apiBaseUrl as WordPress secrets. The key is never retrievable again.
  • Poll GET {apiBaseUrl}/api/integrations/wordpress/stories with x-ermis-api-key. Dedupe by storyId yourself — the export does no dedupe, and slug is literally the storyId.
  • Treat a 403 as a plan or standing problem, not a bad key; treat a 401 as a revoked key.
  • Call POST /api/integrations/wordpress/disconnect with the key when the site uninstalls. It is idempotent.

Σε αυτή τη σελίδα