Skip to content

Procore Connect 2.0.0 — rewrite as a standards-compliant WordPress plugin - #1

Merged
ibuilder merged 9 commits into
mainfrom
feat/v2-rewrite
Aug 7, 2026
Merged

Procore Connect 2.0.0 — rewrite as a standards-compliant WordPress plugin#1
ibuilder merged 9 commits into
mainfrom
feat/v2-rewrite

Conversation

@ibuilder

@ibuilder ibuilder commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Why

Version 1.x could not work against the live Procore API on any install, regardless of configuration. It sent its token request to api.procore.com/oauth/token; Procore serves authentication from login.procore.com/oauth/token, a different host. No access token was ever issued.

Underneath that, it sent the company scope as a company_id query parameter where Procore requires the Procore-Company-Id header, stored the client secret as plaintext in an autoloaded option, ran its connection test from an unverified $_POST, registered its settings with no sanitize_callback, published every team member's email address by default, and cached nothing at all against an API with a documented ten-second spike limit.

This is a rewrite. All eighteen shortcode tags from 1.x are preserved, along with the legacy id, sort_by, sort_order and show_details attributes, so existing pages render unchanged.

What's in it

Correctness

1.x 2.0.0
Token host api.procore.com login.procore.com
Company scope ?company_id= Procore-Company-Id header ✅
Pagination Page one only, silently Follows Link: rel="next"
Sorting array_multisort misaligned rows Null-safe, empties sort last
Sparse payloads PHP warnings Null-safe throughout
Caching None Two-layer, per-endpoint TTL
Rate limits Unhandled Headers read, 429/503 backoff, circuit breaker

Security

  • Client secret and tokens encrypted with AES-256-GCM, keyed from the site's salts, in a non-autoloaded option. wp-config.php constants take precedence and never reach the database.
  • The secret is never rendered into a form field — the input shows a mask.
  • Nonce + capability checks on every admin action; sanitize_callback on the settings option.
  • Emails suppressed by default, needing two independent opt-ins.
  • Procore error messages restricted to administrators.
  • An endpoint allow-list; Link headers pointing off-host are refused.
  • OAuth callbacks verified against a single-use state.

New capability

Eleven new shortcodes (RFIs, submittals, punch list, observations, daily logs, change orders, schedule, vendors, offices, project map, generic reader), Gutenberg blocks, an optional read-only REST proxy, WP-CLI commands, and theme template overrides via yourtheme/procorewp/.

The connection test now reports six stages separately and probes every endpoint against the Procore permission it requires — the usual cause of an empty shortcode is a valid token whose service account lacks read access on one specific tool, which "connection successful" never surfaced.

Verification

  • PHPCS WordPress-Extra + WordPress-Docs + PHPCompatibilityWP — 49 files, zero errors, zero warnings
  • PHPUnit — 59 tests, 270 assertions, against fixtures via an injected transport (no credentials or network needed)
  • php -l — clean; PHP 7.4–8.4 compatible
  • Distributable — 456 KB, 50 files, no dev tooling; header/readme.txt version parity checked
  • Plugin Check — runs in CI against the staged build

Two of the new tests found real bugs while being written (reverse sort promoted empty records; the class sanitizer needed asserting on its actual guarantee). Both fixed in the commits that cover them.

Still needs a human

Testing against a real Procore account: enter DMSA credentials, run Test connection, confirm the permission probe matches your app manifest, place shortcodes, and exercise the Authorization Code flow.

Procore versions each resource independently and several tools have moved paths between releases. The endpoint registry is filterable via procorewp_endpoints precisely so a path can be corrected without forking, and the probe table shows which ones resolve for your account.

⚠️ Upgrade note

The main file is renamed index.phpprocorewp.php (required for Plugin Check and wordpress.org). WordPress treats it as a different plugin, so it must be activated once — settings migrate automatically. Because 1.x never authenticated, re-run the connection test afterwards, and rotate the client secret, which 1.x stored as plaintext.

Not merged yet

Per the plan, this stays on the branch until you've reviewed it. GitHub Pages is not enabled yet either — say the word and I'll turn it on so docs/ publishes.

🤖 Generated with Claude Code

ibuilder and others added 9 commits August 7, 2026 10:41
Adds Composer with PHPCS (WordPress-Extra + WordPress-Docs), PHPUnit,
a .distignore for release builds, editor config and the GPL-2.0 text.

Removes index.php, includes/ and the 1.x stylesheet. They are replaced
wholesale in the following commits: the 1.x entry point authenticated
against the wrong Procore host and could never obtain a token, so there
is nothing in it worth carrying forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the 1.x request code, which could not work against the live API.

Authentication now posts to login.procore.com, Procore's actual token
host. 1.x posted to api.procore.com, a different origin, so no token was
ever issued on any install regardless of configuration.

The company scope is now sent as the Procore-Company-Id header. 1.x
appended it as a company_id query parameter, which most endpoints reject.

Also here:

- Both grants. Client Credentials for a Developer Managed Service
  Account, and Authorization Code on behalf of a Procore user. Refreshes
  are serialised behind a lock because Procore invalidates a refresh
  token the instant it is exchanged, so two concurrent front-end
  requests would otherwise lock the site out permanently.
- Credentials and tokens encrypted at rest with AES-256-GCM keyed from
  the site's own salts, in a non-autoloaded option. 1.x kept all three
  as plaintext in an option read on every page request site-wide.
  wp-config.php constants take precedence and never reach the database.
- Two-layer caching: a fresh entry per endpoint TTL, plus a long-lived
  stale copy served when Procore is unreachable, so an outage degrades
  a published page instead of blanking it. Backed by transients, so
  Redis and Memcached work with no configuration.
- Rate-limit handling: records X-Rate-Limit-*, honours Retry-After,
  retries 429/503 with exponential backoff plus jitter, and opens a
  circuit breaker after five consecutive failures.
- Pagination by following Link: rel="next", refusing any link pointing
  off the configured API host. 1.x silently returned page one only.
- An endpoint allow-list. Nothing outside src/Api/Endpoints.php is
  callable, which is what later makes [procore_data] and the public
  REST proxy safe to expose.
- Environment resolution for production, both sandboxes and custom
  regional or federal hosts, with tokens scoped per environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every 1.x shortcode tag is preserved, and the legacy id, sort_by,
sort_order and show_details attributes still work, so pages written for
the old plugin render unchanged. Eleven new shortcodes cover RFIs,
submittals, punch lists, observations, daily logs, change orders,
schedule tasks, vendors, offices, project locations, plus a generic
allow-listed reader.

Behaviour fixes over 1.x:

- Sorting no longer corrupts result sets. array_multisort() over
  array_column() misaligns rows as soon as any record lacks the sort
  column; records with no value now sort last in both directions and
  stay attached to their own row.
- Sparse Procore payloads no longer emit PHP warnings.
- The stylesheet loads only on pages that actually contain plugin
  output, rather than on every front-end request.

Security and privacy:

- register_setting() has a sanitize_callback. 1.x had none, so an
  arbitrary array could be written to the option.
- Email addresses are suppressed by default and need two independent
  opt-ins to publish; even then they pass through antispambot(). 1.x
  published every team member's address on a public page by default.
- Procore error messages name accounts and permissions, so they reach
  administrators only; visitors get a neutral notice.
- [procore_project_data] reads from a field allow-list rather than any
  key in the payload.
- All cell content goes through Format::cell(), which escapes
  everything it returns, keeping the escaping guarantee auditable in
  one place.

Markup now lives in templates/ and is overridable from
yourtheme/procorewp/. 1.x told users to edit files inside the plugin
directory, which lost their work on every update; custom CSS moves to a
sanitized admin setting printed via wp_add_inline_style().

uninstall.php removes credentials, tokens, cache and options unless the
site opts to keep its data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tabbed Procore menu: Connection, Display, Cache, Shortcodes, Status
and Tools.

The connection test replaces 1.x's binary success/failure. It reports
six stages separately and then probes every registered endpoint,
reporting each as readable, failed or skipped alongside the Procore
permission it needs. The common real-world failure is a perfectly valid
token whose service account lacks read permission on one tool, and
"connection successful" tells you nothing about that.

Every admin action verifies a nonce and the manage_options capability.
1.x ran its connection test from an unverified $_POST key.

The client secret is never rendered back into the form. The field shows
a mask, submitting it blank keeps the stored value, and there is an
explicit checkbox to delete it. 1.x echoed the real secret into an
input value attribute.

The OAuth round trip binds a single-use state value to a transient and
refuses any callback that does not match, which is what stops someone
grafting their own Procore account onto the site via a crafted URL.

The Shortcodes tab is generated from the same registry the shortcodes
use, so the reference cannot drift from the code. Status surfaces
rate-limit headroom, circuit state, token expiry, cache statistics and
a redacted diagnostic log.

admin.js is plain ES5, no build step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One block type with a variation per shortcode, so the inserter is
populated without eighteen near-identical block types. It renders
server-side by delegating to the same shortcode handler, so markup,
escaping and caching are identical in both editing surfaces and there
is no second rendering path to keep in sync.

The editor script is hand-written ES5 against the global wp.* runtime.
No bundler, no node_modules. Shipping a minified bundle without its
source is a wordpress.org review flag, makes the plugin unauditable and
puts Node between a contributor and a one-line fix.

The REST proxy at /wp-json/procorewp/v1/ is off by default and
read-only. It serves from the same cache the shortcodes use, so
exposing it cannot blow the rate limit, and resolves only against
endpoints marked public in the registry, so identity endpoints such as
/me and /companies return 404. Credentials never reach the browser, and
error detail is restricted to administrators.

WP-CLI: test, doctor, projects, cache-clear, cache-warm, reset-token.
`test` exits non-zero on failure so it works as a deploy gate, and
`doctor` reports whether credentials are set without printing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59 tests, 270 assertions, run against JSON fixtures through an injected
HTTP transport, so no Procore credentials and no network access are
needed. WordPress is shimmed to only the functions the code under test
actually calls.

Coverage is aimed at the things that broke in 1.x and the things that
are easy to regress: the Procore-Company-Id header being present,
requests reaching the API host and never the login host, Link-header
pagination including refusing off-host links, 429 and 503 backoff,
cache hits and the stale fallback, the circuit breaker, encryption
round-trips and secret masking, endpoint allow-list refusals, settings
sanitization, legacy attribute aliases, and every shortcode's output
escaping and email suppression.

Two of these tests found real bugs while being written: sort_by()
promoted records with no value to the top when reversed, and the class
sanitizer needed asserting on what it actually guarantees rather than
on a specific output string. Both are fixed in the code they cover.

languages/procorewp.pot carries 293 strings with their translator
comments. 1.x declared a text domain and contained no translation calls
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
readme.txt for wordpress.org, with headers matching the plugin header
exactly, since Plugin Check compares them, and a third-party service
disclosure naming the Procore hosts contacted and the data sent.

README.md rewritten with a shortcode table mapping each one to the
Procore permission it needs, which is the question people actually get
stuck on.

docs/ is a Jekyll site for GitHub Pages: installation, authentication
including a Developer Portal and Permissions Builder walkthrough,
shortcode reference, blocks, caching and rate limits, templating, REST,
WP-CLI, troubleshooting and an upgrade guide.

The upgrade guide leads with the two things a 1.x user must do: activate
once, because the main file was renamed from index.php, and re-run the
connection test, because 1.x never obtained a token. It also says
plainly that the 1.x client secret should be treated as exposed, since
it was stored as plaintext in an autoloaded option.

CI: PHPCS on WordPress-Extra and WordPress-Docs, php -l across PHP 7.4
to 8.4, PHPUnit on four versions, JS syntax and block.json validation,
Plugin Check against the staged distributable rather than the dev tree,
a release workflow that fails on any version mismatch between the tag,
the plugin header and readme.txt, and Pages deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plugin Check ran against the staged distributable and reported six
items. Four are fixed here:

- Removed the Update URI header. It is not permitted on plugins hosted
  at wordpress.org, and GitHub releases do not need it.
- Tested up to 7.0, which is the current WordPress release. 6.9 tripped
  the "not tested against current" error.
- Trimmed the 2.0.0 upgrade notice to 237 characters, under the 300
  character limit.
- Removed the load_plugin_textdomain() call. WordPress has loaded
  translations just-in-time since 4.6 for any plugin whose text domain
  matches its directory name.

Two remaining findings are about the name "ProcoreWP" containing the
restricted term "wp", which wordpress.org disallows in both the plugin
name and the slug. That is a project naming decision rather than a code
fix, so it is left for review. It does not affect GitHub or self-hosted
distribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plugin Check reported "wp" as a restricted term in both the plugin name
and the slug. wordpress.org rejects that outright in either position, so
"ProcoreWP" could never have been listed in the directory.

The plugin is now Procore Connect with the slug procore-connect. The
GitHub repository is unchanged and still lives at ibuilder/ProcoreWP;
only the distributed plugin identity moved.

Renamed throughout: main file, text domain, namespace (ProcoreConnect),
PROCORE_CONNECT_ constants, procore_connect_ options, hooks, filters and
transients, procore-connect- CSS classes and script handles, the REST
namespace, the block name, the theme template override directory, the
WP-CLI command and the POT file.

Shortcode tags are untouched. They were already procore_ prefixed, so
every page written for 1.x still renders.

Historical references to 1.x still name it ProcoreWP, because that is
what it was released as.

Verified after the rename: PHPCS clean across 49 files, 59 tests and 270
assertions passing, php -l and JS syntax clean, and the staged
distributable is 50 files at 456 KB with the correct directory name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ibuilder ibuilder changed the title ProcoreWP 2.0.0 — rewrite as a standards-compliant WordPress plugin Procore Connect 2.0.0 — rewrite as a standards-compliant WordPress plugin Aug 7, 2026
@ibuilder

ibuilder commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Update: renamed to Procore Connect

Plugin Check flagged wp as a restricted term in both the plugin name and slug — wordpress.org rejects that outright in either position, so ProcoreWP could never have been listed. Renamed the distributed plugin to Procore Connect / procore-connect.

The GitHub repo is unchanged — still ibuilder/ProcoreWP. Only the plugin identity moved.

Renamed throughout: main file, text domain, namespace (ProcoreConnect), PROCORE_CONNECT_ constants, procore_connect_ options/hooks/filters/transients, procore-connect- CSS classes and handles, REST namespace, block name, theme override directory (yourtheme/procore-connect/), the WP-CLI command (wp procore-connect) and the POT file.

Shortcode tags are untouched. They were already procore_ prefixed, so every page written for 1.x still renders.

CI status

Check Result
Plugin Check zero errors, zero warnings
Coding Standards (PHPCS) ✅ 49 files clean
Tests (PHPUnit) ✅ 59 tests, 270 assertions

The four fixable findings from the earlier run are also resolved: removed the Update URI header (not permitted on wordpress.org), Tested up to: 7.0, trimmed the upgrade notice under 300 characters, and dropped the load_plugin_textdomain() call.

@ibuilder
ibuilder merged commit 4677021 into main Aug 7, 2026
13 checks passed
@ibuilder
ibuilder deleted the feat/v2-rewrite branch August 7, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant