Skip to content

ACC + Procore Activation Runbook (roadmap R1.4 + R2.4)

Status: adapters are built, fixture-tested, and dormant — they activate per-tenant when a project admin connects a vendor. This runbook covers the platform-side app registration, env vars, tenant connection, webhook registration, verification, and monitoring.

Grounding: written from apps/server/src/modules/integrations/acc/** (client.ts, routes.ts, model-sync.ts, issue-sync.ts, issue-client.ts, webhooks.ts), .../integrations/procore/** (client.ts, routes.ts, sync.ts, webhooks.ts), .../integrations/routes.ts (generic connect), store.ts, config-secrets.ts, apps/server/src/config.ts.


1. Architecture in one paragraph

Both adapters layer on a shared foundation: connections live in integration_connections (one per (project, vendor)), credentials are AES-256-GCM sealed at rest (modules/integrations/store.ts:8-12,46-49), secret-shaped keys inside config (notably webhookSecret) are sealed too via sealConfig (config-secrets.ts, Phase-2 T-2.1; readSecret accepts both sealed and legacy plaintext so old rows keep verifying). Every sync outcome lands in the sync_events ledger (recordSyncEvent). Webhooks arrive on unauthenticated routes that fail closed on a bad HMAC.

2. Vendor app registration

2.1 ACC / Autodesk Construction Cloud

ACC reuses the same APS server-to-server app as the derivative pipeline for its API base URL and client pair (acc/client.ts:8-14: "ACC reuses the SAME APS app (config.APS_BASE_URL) but with the user's 3-legged OAuth token"). So:

  1. Use (or create) the APS app from docs/runbooks/aps-activation.md.
  2. Add 3-legged OAuth to that app with an allow-listed redirect URL, and request Data Management scopes: at minimum data:read + user-profile:read (add data:write only if issue push-back is enabled later — model sync is pull-only by design, model-sync.ts:27: "links are pull-only and geometry is never pushed back").
  3. The 3-legged grant can come from Autodesk sign-in if AUTODESK_CLIENT_ID / AUTODESK_CLIENT_SECRET are configured (config.ts:105-110 — the sign-in grant is reusable by the ACC adapter). Otherwise obtain the token/refresh token out-of-band and paste them into the connect call below.
  4. Record the target ACC hub id and project id (accProjectId) — browse endpoints exist to discover them (Section 4).

2.2 Procore

  1. Register a developer app at https://developers.procore.com (App Type: standard / oauth2).
  2. Set the OAuth redirect URL to your deployment's callback (the token exchange happens out-of-band or through your IdP flow; BimZone stores the resulting bundle — see Section 4).
  3. Scopes: read access to Projects, Company/Project Directory, Issues and RFIs (pull), plus write to Issues/RFIs if push is enabled (procore/sync.ts:26-31 pushes BimZone-origin issues/rfis).
  4. Note the app's client id/secret for env vars, and whether you must hit a sandbox/EU host — per-connection baseUrl override exists for exactly this (procore/routes.ts:63-75, validated by assertPublicHttpsUrl; client re-validates https+public, procore/client.ts:74-86).

3. Environment variables

Exact names from apps/server/src/config.ts:

Env var Used by Notes
APS_CLIENT_ID, APS_CLIENT_SECRET, APS_BASE_URL ACC client (acc/client.ts:61-63) and APS derivative handoff shared with R1.1; see aps-activation.md
AUTODESK_CLIENT_ID, AUTODESK_CLIENT_SECRET optional 3-legged sign-in reuse for ACC (config.ts:108-109) separate app from the data app
PROCORE_CLIENT_ID, PROCORE_CLIENT_SECRET Procore client (procore/client.ts:85-86) used for token refresh against the connection
PROCORE_BASE_URL Procore client default (config.ts:135, https://api.procore.com) overridable per-connection via config.baseUrl
INTEGRATIONS_ENABLED global outbound kill switch (config.ts:126) defaults ON; checked per-job by the sync worker (jobs/integration-sync.ts:24-30)

Per-tenant values (tokens, webhook secrets, project links) are NOT env vars — they live encrypted on the connection row.

4. Connecting a tenant

Two paths exist:

  • Generic connect endpointPOST /api/projects/:projectId/integration-connections/:vendor/connect (modules/integrations/routes.ts:61-138). Project-admin gated (requireProjectRole(...,"admin")) plus plan entitlement (requireEntitlement(...,"integrations")). Accepts {authKind, credentials, config, scopes}. This is what the web UI's ConnectVendorForm posts to (apps/web/src/pages/project/ProjectDetailPage.tsx:421-451).
  • Procore-specific connectPOST .../integrations/procore/connect (procore/routes.ts:49-118): takes {accessToken, refreshToken?, expiresAt?, scopes?, baseUrl?, webhookSecret?}, stores authKind oauth2.

Connection steps per tenant:

  1. Admin connects the vendor (form or API). Credentials are never echoed back — responses go through publicConnection() which redacts secrets (routes.ts:34-47).
  2. ACC (customer OAuth, WS-INT-ACC-OAUTH): the Integrations tab's Connect button runs GET /api/projects/:projectId/integrations/acc/oauth/start → Autodesk consent → GET /api/integrations/acc/oauth/callback. The redirect URI registered in the Autodesk Developer Console MUST include exactly ${PUBLIC_URL}/api/integrations/acc/oauth/callback, and the app needs AUTODESK_CLIENT_ID/AUTODESK_CLIENT_SECRET + scope openid profile data:read (acc/oauth.ts). After connect, pick hub → project → models in the tab; import persists config.accProjectId on the connection.
  3. Procore: link the tenant to one company+project via POST .../integrations/procore/link {companyId, projectId} (procore/routes.ts:143-158).

Reconnect semantics — reference the fix: reconnect MERGES config over the stored blob instead of replacing it (routes.ts:87-97): "wholesale replacement silently wiped unrelated keys other surfaces depend on (webhookSecret …, accProjectId …)". The Procore route preserves previously-linked company/project the same way (procore/routes.ts:90-91). When rotating ONLY a credential, do not send a config object that omits existing keys expecting merge-from-scratch behaviour elsewhere. Concurrent double-connects reconcile to the winner via the unique-index race handler (routes.ts:125-133), not a 500.

5. Webhook registration + secret handling

There is no code that auto-registers hooks with the vendors — register manually (gap noted in §9):

ACC

  • Endpoint: POST <PUBLIC_URL>/api/webhooks/acc/:connectionId (acc/routes.ts:114-136).
  • Create the hook in ACC (or via the Autodesk Webhooks API) subscribed to dm.version.added, issue.created, issue.updated (webhooks.ts:64, classifier at webhooks.ts:67-75).
  • Signature: HMAC-SHA256 of the exact raw body in x-adsk-signature (strips the sha256hashedPayload= prefix), constant-time compare, fails closed → 401 (acc/webhooks.ts:43-61). The raw body is preserved by a plugin-scoped JSON parser (acc/routes.ts:51-60) — a re-serialized body would silently never match (:119-124).
  • Secret storage: put the hook secret into the connection via a reconnect carrying config: { webhookSecret }. It is sealed at rest (store.ts:47-49); reads go through the envelope-aware readSecret (acc/webhooks.ts:19-24). It is stripped from any public projection by redactConfig (routes.ts:41).

Procore

  • Endpoint: POST <PUBLIC_URL>/api/webhooks/procore (procore/routes.ts:184-222).
  • Create the Procore webhook for the linked company/project; Procore sends procore-company-id on every delivery — the endpoint REQUIRES it and narrows candidate connections in SQL before doing any HMAC work (DoS bound, :187-206).
  • Signature: HMAC-SHA256 hex in x-procore-signature (procore/webhooks.ts:34-52), fails closed.
  • Deliveries coalesce into at most one in-flight pull per connection (coalesceByKey, procore/routes.ts:214-221) instead of N concurrent full re-pulls.
  • Pass the shared secret at connect time (webhookSecret field) or reconnect with it merged in.

6. Verification checklist (staging tenant)

Models: 1. [ ] Connect ACC, set accProjectId, run POST .../integrations/acc/import with an item urn → creates the HEAD of a NEW version group, two external links (item urn = lineage identity, version urn = row), sync event status: ok (acc/model-sync.ts:95-146). 2. [ ] Upload a new version in ACC (or fire a test dm.version.added) → new model row in the SAME versionGroupId, versionNumber = max+1, atomic demote-all/promote-one tx keeps exactly one current (model-sync.ts:158-262, partial-unique index models_one_current_per_group). 3. [ ] Replay/duplicate delivery of the same version urn → no-op with reason: "version-already-linked" / "version-already-claimed" (model-sync.ts:174-180,236-242). 4. [ ] .rvt import routes to the APS derivative queue; .ifc imports convert locally (routeConversion, model-sync.ts:74-88) — requires R1.1 activation first.

Issues (both vendors): 5. [ ] Pull: POST .../integrations/procore/pull / ACC pullIssues — items appear, links recorded. 6. [ ] Push: POST .../integrations/procore/push — only BimZone-origin rows (source != 'integration') push; pushed items gain links (procore/sync.ts:324-397). 7. [ ] Loop prevention: a just-pushed item is NOT re-pulled and a pulled item is NOT re-pushed — the combined push-then-pull shares the loop-prevention link set (sync.ts:385-397,427); ACC issue pull no-ops when the remote etag equals the stored link etag (acc/issue-sync.ts:17-19). 8. [ ] Conflict path: an item edited on both sides resolves by etag comparison on the stored link — verify a deliberately divergent issue records the skip/conflict event in sync_events rather than duplicating. 9. [ ] Bad signature → 401 both vendors (fire an unsigned curl at each webhook). 10. [ ] Kill switch: flip INTEGRATIONS_ENABLED=false mid-run → worker logs skipped events with a stated reason, browse/disconnect still work (integration-sync.ts:24-30, guard.ts:8-14).

7. Monitoring

  • sync_events ledger — every pull/push/webhook/sync_run outcome with status ok/error/skipped; readable per connection at GET /api/projects/:projectId/integration-connections/:id/events?cursor&limit (routes.ts:151-164). Connection health surfaces via lastSyncedAt / lastError in the UI (ProjectDetailPage.tsx:402-403).
  • DLQ — the integration sync queue dead-letters into QUEUES.integrationSyncDlq (lib/queue.ts:119); the DLQ worker logs permanently failed syncs (jobs/integration-sync.ts:43-45) and the queue is exported in telemetry metrics (lib/telemetry/metrics.ts:57-72). Alert on any non-zero DLQ depth.
  • Audit trail — connect/disconnect/link/sync actions are audited (integrations.connected, integrations.linked, integrations.syncedroutes.ts:135, procore/routes.ts:115,155,168,179).
  • Gap: there is no scheduled recurring full-reconcile job per connection today — webhook-driven + manual push/pull only. If webhooks stall (secret rotation, vendor outage), data goes stale silently except for lastSyncedAt age. Monitor lastSyncedAt freshness per connection.

8. Rollback

  • Disconnect: DELETE .../integration-connections/:id purges credentials and marks disconnected, keeping mapping/ledger history (routes.ts:140-149).
  • Rotate a leaked webhook secret: update it at the vendor, then reconnect with only config: { webhookSecret } — the merge fix guarantees other keys survive.
  • Global off: INTEGRATIONS_ENABLED=false stops all outbound sync traffic while leaving read-only browse and revocation available (config.ts:117-126).

9. Gaps surfaced by this review

  1. No programmatic webhook registration (hooks are created manually per vendor/tenant; nothing records the hook id on the connection).
  2. No scheduled periodic reconcile — staleness detection is manual (lastSyncedAt watching).
  3. ACC issue push exists in code (pushIssues, acc/issue-sync.ts:19) but has no dedicated HTTP route (only pull is exposed in acc/routes.ts); enabling push-back needs either a route or an explicit decision to stay pull-only.