Skip to content

0028. Bind Positive User connections to their workspace by subdomain

Date: 2026-09-17 Status: Accepted

Context

The Push-Out initiative makes Positive User (user.com) a target Conflux writes into: customer data flows out of Conflux into a workspace on user.com, one workspace per merchant, with products to follow and orders left out of scope while the mapping onto their Deal entity is unbuilt. Before a delivery can be attempted, the connection needs credentials, and the epic this work belongs to records the risk explicitly rather than treating it as an ordinary bug class: reaching the wrong workspace is a data-protection incident. A wrong token or a wrong subdomain does not fail to authenticate — it authenticates successfully, against a different account, and writes one merchant's customers into another merchant's workspace.

Every other registered integration (WooCommerce, PrestaShop) treats SystemConnection::base_url as the one source of the host it talks to, with a platform-issued key or token layered on top for authentication. Positive User has no install-specific base URL to read the same way: every workspace lives at <subdomain>.<apex> — one shared apex domain per estate, user.com in production and staginguser.com on staging — and the subdomain is the account identifier the same way a shop's own URL identifies a store. That structural difference is what makes "authenticates but reaches the wrong workspace" possible here in a way it mostly is not for the shop platforms: a WooCommerce token valid for the wrong store still has to be paired with that store's own URL, which is wrong on its face. A Positive User API token is workspace-bound but does not itself name the workspace, so a request routed at the wrong subdomain with a token that happens to be valid there would authenticate without complaint.

Three ways to name the host were considered:

  1. Keep base_url as the host, the way WooCommerce and PrestaShop already work, and treat the token as the only credential. Rejected: it reintroduces the two-sources-for-one-host problem this design needs to avoid, not sidestep. base_url and a workspace identifier could disagree, and whichever one a request actually reads decides which workspace a delivery reaches — that drift is exactly the class of mistake that puts one merchant's data in another merchant's account.
  2. Store the full base URL as a credential (e.g. https://<subdomain>.user.com) instead of a bare subdomain. Rejected: it moves the same risk into a different field without removing it. Nothing about a free-form URL prevents it from naming a host outside the apex at all, and checking that a supplied value has the expected shape is work a bare subdomain does for free — a subdomain is one DNS label and nothing else can hide inside it.
  3. Bind the credential to a workspace, and verify by reading something workspace-scoped rather than only checking that the token authenticates. Chosen.

A second question sits on top of the first: what should verification prove? Reusing the pattern the shop integrations use — call an authenticated endpoint, treat any success as proof the credentials work — only proves the token is valid somewhere, not that it is valid at the subdomain this connection names. The most direct way to prove the latter beyond doubt would be to list the workspace's own contacts and confirm the response looks like the intended account. That was considered and rejected: the design for this integration is that a workspace's own people are never read by Conflux — only records Conflux itself created are ever addressed — and a verification call that paged through somebody's contacts would break that rule on the very first request this integration ever makes, before a single record has been delivered.

Decision

A Positive User connection's subdomain credential is the sole source of the host it is addressed at: UserPlatformClient::endpoint() builds https://<subdomain>.<apex>/api/public/... from it and the configured apex, and nothing else. The apex is configuration (conflux.user_platform.api_host) rather than a compiled-in constant, and deliberately so: the two estates serve workspaces from different apexes, so the same subdomain names a different workspace on each. That is the same class of mistake as a wrong subdomain, which is why it belongs in configuration that an environment sets once rather than in a value a caller passes per request. SystemConnection::base_url — read as the host by every other registered integration — is not read as one here. SystemSeeder reflects the split: the User Platform system's auth_fields now name two credentials, a non-secret subdomain and a secret api_token, replacing the single api_key field this system previously seeded. There is no data migration for a connection still carrying the old api_key: a subdomain cannot be derived from a bare token, so carrying old rows forward automatically would either leave them broken or guess a host — and guessing a host is the exact mistake this decision exists to rule out. A connection in that state has to be given both new credentials by hand.

A base_url is not ignored outright, though: UserPlatformClient::validateCredentials() refuses a base_url that names a different host than <subdomain>.<apex>, checked before any network call is made. It costs nothing extra to check, and it catches the one way base_url could still cause harm here — a stray or copy-pasted value left over from filling in another driver's connection form.

Verification proves the connection reaches the workspace the subdomain names, not merely that the token is valid somewhere. UserPlatformIntegration::performVerification() calls GET /api/public/attributes/ — the workspace's own custom-attribute schema, not a contact list. This satisfies both constraints at once: the endpoint is scoped to whichever workspace the host addresses (a token issued for a different workspace is rejected there, and a subdomain nobody owns is refused by the apex itself — reaching a workspace at all is reaching the intended one, because the workspace is the host), and reading a schema never touches a person's data. How an unowned subdomain is refused was measured rather than assumed: the apex carries wildcard DNS, so it resolves and completes TLS, and the application answers 410 Gone. 410 is therefore treated as "no workspace here" alongside 404, or the one mistake this decision exists to catch would have surfaced as a status code rather than as the subdomain to correct. Listing the schema is not pure overhead either: an undefined custom attribute is rejected the first time a delivery tries to write one, so this call answers a question the first delivery would otherwise ask on its own. VerificationResult::success() carries the subdomain as the connection's external_user_id — the record, from here on, of which workspace this connection proved it could reach.

Folded into the same decision, because it follows directly from taking "which workspace" seriously: VerificationResult::message is reclassified from an incidental catch-all into a field only integration code writes, in sentences meant for whoever configured the connection — not a raw exception message passed through unexamined. VerifiesConnections::verify() used to pass a caught exception's own message straight through as the failure; it now logs the exception (connection id, integration class, exception class, message) and returns a fixed 'The external system could not be reached.' instead. Every curated failure that UserPlatformClient/UserPlatformIntegration can name explicitly — a missing or malformed subdomain, a token of the wrong length, a rejected token, a subdomain that resolves nowhere, a base_url/subdomain mismatch — is still returned as its own sentence via VerificationResult::failure($message), distinct from the fixed sentence a thrown exception now produces. Nothing in Conflux reads VerificationResult::message today — RegisterSystemConnectionController, ConnectSystemConnectionController and the Filament ConnectSystemConnectionAction all answer a failed verification with the same fixed string regardless of it, for every integration, and that is unchanged by this decision. A Positive User connection never reaches those controllers in the first place: they are the plugin handshake used by the shop integrations, and Positive User credentials arrive by a route defined elsewhere, not built yet. Curating the message now, and reclassifying what an exception leaves behind, is done anyway: an Illuminate\Http\Client\RequestException message carries the request URL, the response body and sometimes headers — exactly the detail ApiErrorCode::detail() exists elsewhere in the API to keep out of a response a merchant can read — and the first caller that reads VerificationResult::message at all should not be the one that discovers this by accident. Whoever builds the route that finally calls verify() for a Positive User connection inherits a field that is already safe to show.

Consequences

A subdomain is now load-bearing in a way a single credential could never be: pairing a valid token with the wrong subdomain fails outright, rather than quietly authenticating against the wrong account, and that failure is the entire point of the design — it surfaces as a rejected token or an unresolvable host, never as a silent misdelivery. The cost is a second credential to collect, validate and show back to whoever configures the connection, where the shop integrations get by with one credential plus a URL they were already given.

No connection can migrate its own credentials from the old single api_key shape to the new pair automatically: any connection seeded or created before this change either has to be given both subdomain and api_token by hand, or stays unusable. This is accepted rather than automated because there is no source to derive a subdomain from a bare API key.

Verification now costs one read of workspace-scoped data on every attempt — the custom-attribute schema — where the shop integrations cost none. This is also, deliberately, the only workspace data this integration ever reads: should a future capability need to read a workspace's own contacts for some other purpose, this decision's rejection of that approach for verification does not automatically extend to that purpose; this ADR concerns proving which workspace a connection reaches, not what a delivery is allowed to read once connected.

The message-handling change reaches every registered integration through the shared VerifiesConnections trait, not only UserPlatformIntegration, but it has no observable effect today: every current caller of verify() (RegisterSystemConnectionController, ConnectSystemConnectionController, the Filament ConnectSystemConnectionAction) already discards VerificationResult::message in favour of a fixed string, for every integration, so logging a thrown message instead of carrying it changes nothing any of them show. What it changes is what is true of the field from now on: an integration that can name a failure should return it rather than throw, because a returned message is written for the reader and a thrown one is not. That is the standard UserPlatformClient/UserPlatformIntegration are held to throughout this decision, and the trait now holds every future caller of verify() to it too, whenever one is built.