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:
- Use (or create) the APS app from
docs/runbooks/aps-activation.md. - 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(adddata:writeonly 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"). - The 3-legged grant can come from Autodesk sign-in if
AUTODESK_CLIENT_ID/AUTODESK_CLIENT_SECRETare 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. - Record the target ACC hub id and project id (
accProjectId) — browse endpoints exist to discover them (Section 4).
2.2 Procore¶
- Register a developer app at https://developers.procore.com (App Type: standard / oauth2).
- 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).
- 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-31pushes BimZone-origin issues/rfis). - Note the app's client id/secret for env vars, and whether you must hit a sandbox/EU host —
per-connection
baseUrloverride exists for exactly this (procore/routes.ts:63-75, validated byassertPublicHttpsUrl; 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 endpoint —
POST /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'sConnectVendorFormposts to (apps/web/src/pages/project/ProjectDetailPage.tsx:421-451). - Procore-specific connect —
POST .../integrations/procore/connect(procore/routes.ts:49-118): takes{accessToken, refreshToken?, expiresAt?, scopes?, baseUrl?, webhookSecret?}, stores authKindoauth2.
Connection steps per tenant:
- Admin connects the vendor (form or API). Credentials are never echoed back — responses go through
publicConnection()which redacts secrets (routes.ts:34-47). - 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 needsAUTODESK_CLIENT_ID/AUTODESK_CLIENT_SECRET+ scopeopenid profile data:read(acc/oauth.ts). After connect, pick hub → project → models in the tab; import persistsconfig.accProjectIdon the connection. - 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
configover 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 atwebhooks.ts:67-75). - Signature: HMAC-SHA256 of the exact raw body in
x-adsk-signature(strips thesha256hashedPayload=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-awarereadSecret(acc/webhooks.ts:19-24). It is stripped from any public projection byredactConfig(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-idon 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 (
webhookSecretfield) 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 vialastSyncedAt/lastErrorin 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.synced—routes.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
lastSyncedAtage. MonitorlastSyncedAtfreshness per connection.
8. Rollback¶
- Disconnect:
DELETE .../integration-connections/:idpurges 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=falsestops all outbound sync traffic while leaving read-only browse and revocation available (config.ts:117-126).
9. Gaps surfaced by this review¶
- No programmatic webhook registration (hooks are created manually per vendor/tenant; nothing records the hook id on the connection).
- No scheduled periodic reconcile — staleness detection is manual (
lastSyncedAtwatching). - ACC issue push exists in code (
pushIssues,acc/issue-sync.ts:19) but has no dedicated HTTP route (only pull is exposed inacc/routes.ts); enabling push-back needs either a route or an explicit decision to stay pull-only.