Skip to main content

Trial hubs

Every Helix trial prospect used to land in one shared hub, so they saw each other's data and none of them got the admin experience. Prospect can now gets a hub of their own, cloned from a seed hub that acts as the template. The old method still works for cases where they don't need admin.

The whole thing is synchronous: POST /api/trial-hubs returns only once the hub is usable, so a caller can send someone straight to it. That matters for the LinkedIn signup flow, which has nowhere to send the user if provisioning is a background job.

Seed hubs​

A seed hub is an ordinary hub with isSeedHub set in domains.config, switched on from Admin → Settings → Helix trial setup. It is the template: whatever it contains is what a trial hub gets.

Requests are made against the seed hub's own hostname, not against a central endpoint. The hub being cloned is therefore the caller's own hub, so the read side is covered by ordinary domain scoping, and adding a new trial scenario needs no deploy — set up another seed hub, turn the flag on, mint an access key, and branch in Zapier.

requireSeedHub guards both routes and returns 403 for a hub without the flag. It checks the flag before the environment's root domain, so an environment with no wildcard turns ordinary hubs away as it always would, rather than answering 503 and raising an alert for each one.

The endpoints​

api/routes/trialHubs/, mounted unprefixed under /api from api/routes/admin.js.

RouteGuardsPurpose
POST /api/trial-hubsisAdmin, requireSeedHub, rateLimitTrialHubsClone the seed hub. Body is all optional: email, first_name, last_name, subdomain.
GET /api/trial-hubs/defaultswithPermission(ROLE_SUPER), requireSeedHubReturns {rootDomain} so the super-user form can show the fixed suffix.

subdomain is honoured only for super users. An access key always gets a generated one, so a leaked key cannot squat on a recognisable hostname.

The response is {hubUrl, subdomain, domainId, userId?, created}.

Callers​

  • Zapier, behind the HubSpot trial form, using an accessKey scoped to /api/trial-hubs. The scope is offered in the access key modal. Reproduced as commented-out code at the top of api/test/integration/externalApiCalls/zapierCreateTrialHub.spec.js — keep the two in step.
  • Super users, through the Trial hubs tool in the Admin app's Tools menu, which only appears on a seed hub (frontend/src/admin/pages/trialHubs/).
  • SSO links, see LinkedIn signup flow

The older Zapier flow that adds a prospect to an existing shared hub still exists for other funnels — see zapierAddUserToExistingHub.spec.js.

Hostnames​

Generated hostnames are helix-<token> joined to the environment's wildcard root domain, which comes from the HUB_ROOT_DOMAIN env var (api/config/misc.js). Unset, both endpoints return 503 and the super-user form disables creation and says so, so an environment without a wildcard fails cleanly rather than minting broken hostnames. The root domain is not in the frontend's domain config, so the form learns it is unavailable from the failed /defaults request rather than knowing up front.

EnvironmentValue
prod eu5app.com
prod us5app.us
staging eufiveapp.com
staging usfiveapp.us
dev eufiveapp.info
dev usnot set

The token is six characters from a 27-character alphabet with vowels and look-alikes removed, so a generated name cannot spell anything unfortunate and can be read out over a call. That is ~387 million combinations; collisions are retried, and a caller-supplied label is validated as a single DNS label so it can never reach outside the wildcard.

What gets cloned​

api/modules/trialHub/ — createTrialHub.js orchestrates, and the phases run in one pass because each depends on the ids the previous one minted.

StepTables
cloneDomaindomains
cloneHubExtrasemailTemplates, translations, customFields, domainFederations
cloneSeedHubData → teamsteamTypes, team (smart team rules remapped — see below)
cloneSeedHubData → setshelix_set, helix_set_skill, helix_set_team
cloneSeedHubData → usersusers, userDomains, userTeams
cloneSeedHubData → datahelix_meeting, helix_user_analysis, helix_user_meeting, their _skill children, feedback

The seed hub's whole config carries over — feature flags, Mindset uids, branding, sender address — with isSeedHub forced off so a trial hub cannot mint further hubs, and isTrialHub forced on because the HubSpot writes are gated on it.

Rows with domain_id IS NULL are global defaults and need no copy, so a new hub works with zero emailTemplates and zero role rows.

What is deliberately not cloned​

  • Skills. sqlUserSkillJoins joins helix_set on hse.domain_id = u.helix_domain_id but puts no domain filter on helix_skill, so a copied set points straight at the same global skill ids. Copying them would fork them for no gain.
  • Content. Playlists, assets and topics — seed hubs have none. Revisit when they do.
  • A default team type. /auth/start gives an ordinary new hub one; a trial hub gets exactly the seed hub's team types, including none.

Smart teams​

A smart team's smart_settings names other teams by id, in both halves: search is what the admin UI shows and reparses when the rules are edited, and filter is the dare filter built from it by parseUserSearch. Copied verbatim they would name the seed hub's teams, match nobody here, and reconcileSmartTeams would empty the copied team on the next user change.

remapSmartSettings rewrites both once every team has been inserted, which is the first point at which the old-to-new ids are known. A rule naming a team that was not copied loses that id; a rule left with no ids at all cannot be honoured, so the team is copied without smart_settings — keeping the members it was copied with, as a static team — and either case raises an alert. team: searches match on name, which is copied verbatim, so they are left alone.

Date shifting​

Example data has to look recent or the hub reads as abandoned. The shift anchors on the most recent week present in the seed data:

target = Monday of last week
offset = target - MAX(analysis_week_commencement_date) in the seed // whole weeks

That offset is applied to every analysis_week_commencement_date and, in days, to helix_meeting.start_time / end_time. Anchoring rather than pinning preserves the spacing between weeks, and landing on last Monday matches getWeekCommencingDate, which deliberately skips the current week when an earlier one exists. A shifted start_time must stay inside the Monday-start week named by its analysis_week_commencement_date, or reports and reprocessing disagree.

Example users​

Anyone in the seed hub with helix_user_analysis rows, excluding super users and anyone owning an accessKey — the key is the stable identifier, not the account name.

users.name is globally unique, so copied addresses are plus-addressed from the original (jane@x.com → jane+helix-7kq3np@x.com). Any tag the seed address already carries is kept — seed hubs are often built from a single mailbox, where the addresses differ only by their tag, and dropping it would give two copied users the same address and fail the insert. They are created with no passHash, not federated, and never invited, so nothing can email an address derived from a real person's.

Seed hub addresses must not be deliverable

A copied address keeps the seed user's domain, so @example.com seed users produce @example.com copies, which Mailgun will not deliver to. That is what makes the copies safe: a copied account has a live userDomains membership, and the password reset flow does not check passHash, so a deliverable copied address could be reset into a working login. Seed a hub only with addresses nobody receives mail at.

Idempotency​

findExistingHelixHub looks up any non-deleted hub through users.helix_domain_id, matching the submitted address against user_emails as well as users.name - someone can already use Helix under an address they hold as an additional one, and matching only the primary would clone a hub that upsertUsers then refuses to put anyone in. A match returns created: false — nothing is created and nothing about the person changes. Which hub it is, and who they are in it, is returned only to a super user, since that hub may belong to an unrelated customer; an access key is told only that nothing was made. That covers a form submitted twice, a Zapier retry on any non-2xx, and someone who already uses Helix on a real customer hub whose data must not move.

deleted is the one status excluded, because that is what a failed build marks a part-built hub as, and a retry after one has to be able to get a clean hub.

claimUserForHub uses the same predicate when it sets helix_domain_id on the new user, so the two cannot disagree. An earlier version checked helix_domain_id IS NULL there and matched only active hubs in the lookup — someone whose hub had been archived slipped through both, ending up an admin of a trial hub that was not their Helix hub and so showed no skills at all.

Failure handling​

The check for an existing hub and the creation of a new one are separate statements, so two submissions of the same address - or a Zapier retry arriving while the first attempt is still running - could both find nothing and both clone a hub. withEmailLock holds a short redis lock (SET NX PX) on the normalised address for the whole build, and a caller that cannot take it gets a 409. The value is a token the release checks before deleting, so a build that overran the 10s TTL cannot drop the lock a later request has since taken. The client runs with enable_offline_queue: false, because node_redis queues commands while disconnected rather than rejecting them — without it the fallback below would hang instead of firing. It is a de-duplication measure rather than a guarantee: if redis cannot be reached the build goes ahead unlocked, because refusing to create trial hubs because redis is down would be the worse failure.

api/db/MySQLPool.js exposes only query and stream, so a multi-table clone cannot be atomic. The whole build therefore sits in one try; on failure the part-built hub is set to status = 'deleted', which hides it from setTenantMiddleware and keeps it out of the idempotency lookup, so a retry gets a clean hub while the wreckage stays for inspection.

The invitation is deliberately sent after the build finishes rather than by upsertUsers during it, so a failure part-way cannot email someone a link to a hub that is about to be marked deleted.

Do not use maintenance or archived as a "not ready" marker — getHubMemberDetails.js and contentSynchroniser.js both treat those as live.

Alerting​

An HttpError of any status, including 500, is logged by api/middlewares/exception.js at warn level, and Slack alerting keys off the slackAlert message prefix rather than severity. A failed trial hub would otherwise be completely silent. Every failure path therefore raises logger.slackAlert naming the prospect, so a hub that was not created can be created by hand. The exception is a 4xx raised inside the build — a subdomain already taken, or malformed — which only a super user can cause and which the form shows them directly. A rejected request body is the mirror image: a super user sees the error in the form, so nothing is raised, but an access key has nobody watching it, so a prospect turned away by validation is alerted on.

HubSpot​

upsertHubSpotContact early-returns unless the target hub has isTrialHub, which is why cloneDomain forces that flag on. It reads the hub from the primary rather than a read replica, because it runs milliseconds after the hub was created and under replica lag the row would be absent - which reads as "not a trial hub" and silently skips the write. It stamps helix_trial_url with the hub's full URL, and adds the contact to the configured trial list. It swallows its own errors by design, so it must never be awaited in a way that can fail the request.

Only signups reach upsertHubSpotContact: createTrialHub here, and auth/modules/auth.js for the self-serve flow. POST /api/users deliberately does not — people a prospect adds to their own trial hub are colleagues, not trials, and creating contacts for them would put them on the follow-up list and overwrite the helix_trial_url of anyone who already had a hub of their own.

Everything after the signup goes through updateHubSpotContact instead, which answers "should this person's trial activity be recorded?" by asking HubSpot: it is gated on isTrialHub and on if (contact), so it only ever updates somebody marketing already knows and never invents a contact. That is the rule to keep — write on signup, update only what already exists.

Negative tenant caching​

Not specific to trial hubs, but this feature is what exposed it.

@5app/memoize caches a resolved undefined exactly like a real value: the cache-hit test is if (item.value && shouldUseCache(item)), where item.value is the promise — always truthy. A miss was therefore held for the full window: ten minutes in api/middlewares/setTenantMiddleware.js, thirty seconds in app/middlewares/domainManifest.js. That is the long-standing "could not find the site" error for ~30 seconds after creating a hub at /start, and it would have stranded a freshly created trial hub too.

Misses now live in their own bounded map with a ten second TTL. The two caches hold disjoint sets — the memo only ever keeps hubs that exist — because they are bounded separately: if both held misses, evicting one copy would leave the other behind, and unknown hosts would take slots away from real hubs. Both maps are capped, since the Host header is attacker-controlled.

Signing up with LinkedIn​

A prospect follows a marketing link, authenticates with LinkedIn, and lands in a hub of their own, already logged in. No email, no waiting. They can come back later and sign in with LinkedIn from that hub's own login page.

Why there is a broker​

LinkedIn only accepts exact-match redirect URIs, and auth/modules/federation/linkedin.js builds its callback URL from the request host. A trial hub's hostname is generated at runtime (helix-<6 chars>.<root>), so it can never be registered with LinkedIn and can never run a LinkedIn login of its own.

One hostname — SSO_BROKER_HOST, conventionally sso.<root> — therefore owns the callback. It completes the LinkedIn exchange and relays the verified profile on to the hub the login was actually for.

A hub says it wants that by carrying a domainFederations row of type = 'linkedin-relay'. The distinction matters:

typeMeansCredentials
linkedinThe hub runs its own LinkedIn appits own clientID/clientSecret
linkedin-relayThe hub borrows the broker'snone, ever

Credentials are never cloned into a trial hub. Its admin is the prospect, and an admin can read any column of their own hub's domainFederations row, so a copied clientSecret would be theirs for the asking.

Neither the type nor the credentials are forgeable by a hub admin: domainFederations#patch writes only identity and config, and the model has no post at all. Rows are created by a developer or by the clone.

How the browser gets there​

The broker cannot log anybody in itself — the session cookie has no domain, so a session minted on sso.<root> is invisible on helix-xyz.<root>. Instead it mints a handoff token: 32 random bytes in Redis for 60 seconds, consumed atomically on first use, bound to the target hostname and to a hash of the User-Agent.

Signing up and coming back differ only at the two ends. Everything between the broker and the handoff is one path:

The only real difference is what api is asked: signing up names a seed hub and gets one cloned, coming back names an existing hub and only has it checked.

https://<SSO_BROKER_HOST>/auth/federation/login?RelayState=<base64 {"seedHost": "<seed hostname>"}>

Admin → Settings → Helix trial setup has a LinkedIn signup link button that builds this for the hub you are on. It appears only when the hub is a seed hub and the environment has a broker.

The seed host travels inside RelayState rather than as its own query parameter, which makes the link awkward to edit by hand. That is not what makes it safe — anyone can decode base64. What makes a tampered link harmless is that createTrialHubForSso refuses any host without config.isSeedHub, so the worst a changed value can do is name a different real seed hub.

Who can get in​

The linkedin-relay row is the whole gate. Anyone who completes a LinkedIn login against a hub that has one gets in, as an ordinary member — userDomains.role_id defaults to 0, and only the prospect the hub was built for is made an admin.

With one exception: a seed hub is never a login target. It has to carry a relay row, because that row is the template the clone copies, and its hostname is public because the signup link names it. Were it a target, anyone could join the hub every trial is cloned from — and since a new user's helix_domain_id defaults to the hub they registered through, the seed would become their Helix hub permanently and the marketing link would return them to it instead of building them a trial. getRelayTarget refuses seeds, and a seed's own login page does not offer the LinkedIn button.

That is deliberate: it is how a prospect's colleagues join without being invited one at a time. The cost is that anyone who learns a trial hub's hostname can join it, which is acceptable only because a member can see nothing that matters — the analytics routes sit behind teamManagerOrAdmin(..., HELIX_READ), and no content is cloned into a trial hub.

Nobody who arrives through the broker is written to HubSpot. The prospect was recorded when their hub was built; everyone after them is a colleague, not a lead.

Setting up an environment​

Five things, and the feature is silently inert without any one of them.

  1. SSO_BROKER_HOST set to the broker's hostname, on both auth and app. This is done in 5app/deployment, derived the same way HUB_ROOT_DOMAIN is, so there is nothing to do by hand — and no DNS work either, since the environment's wildcard already resolves sso.<root>. Empty disables the relay everywhere, which is how an environment starts.
  2. A domains row for that hostname, plus a domainFederations row holding the real clientID/clientSecret and — the one that catches people — legacy_callback_url = 1. That column defaults to NULL. A LinkedIn callback carries no federation id, so the row is matched by that flag alone, and without it every brokered login answers a bare 401. Exactly one row on the broker hub may claim it.
  3. The redirect URI registered in the LinkedIn developer portal as https://<SSO_BROKER_HOST>/auth/federation/callback. No id in it, so it survives a database rebuild.
  4. A type='linkedin-relay' row on each seed hub, which is what the clone copies into every hub built from it. A seed hub without one refuses every signup outright — it is checked before anything is cloned, so a misconfigured seed costs a 403 per click rather than a hub per click.
  5. config.helixTrialUseCase on each seed hub, recorded against the HubSpot contact. There is a seed hub per use case, so the hub itself says which one — nothing in the link can name a use case the hub's content contradicts. Super-only, like isSeedHub.

Test a returning login before a signup: it exercises the whole relay with no hub creation in the path, so a failure is unambiguous.

What the alerts mean​

Every LinkedIn signup that does not end in a login raises exactly one alert, from createTrialHubForSso.

AlertWhat happenedWhat to do
a signup link named an unusable seed hubThe seed is deactivated, has lost isSeedHub, or has lost its linkedin-relay rowPut it right. Every click on that published link fails until you do, and nothing is created meanwhile
a LinkedIn signup did not log anyone in, reason not_enabledThey already have a hub that never opted into LinkedIn, often a real customer hubUsually nothing — they were sent to that hub's own login page
same, reason is_seed_hubSomething sent a login at a seed hub itselfA seed is never a login target. Find what is linking to it
same, reason not_their_primaryTheir LinkedIn address is a validated additional address on their accountUsually nothing — sent to their own hub's login page. They sign in with their primary address
same, reason address_held_elsewhereAnother account holds the address as an additional email, so no user could be createdSort the accounts out, or give them a different address. Until then this person cannot sign up at all
same, reason no_helix_hubThe account has no helix_domain_id, which nothing should produceInvestigate the account; it predates the column or was changed by hand
nobody was invited into the new hubA hub was built with no membersNamed in the alert. Add them by hand or abandon the hub
SSO relay: refused a loginA returning login named a host with no linkedin-relay row, or named a seed hubEither a crafted link or a hub somebody forgot to set up; the alert says which
SSO relay: LinkedIn says the address is unverifiedLinkedIn returned email_verified: false for the addressRefused. An address is how a returning prospect is identified, so this may be somebody claiming one they do not own
SSO relay: profile was not verified by the brokerA profile reached the relay that the broker's own LinkedIn app did not verifyTreat as hostile until proven otherwise; see 5app/hub#17742
hub ready but the handoff could not be mintedThe hub exists and they cannot reach itSend them the hub named in the alert, plus a password reset

An expired or already-used handoff token is not alerted: back buttons, prefetchers and link scanners all produce it.

Known gaps​

  • Nothing expires or reaps trial hubs. domains.id is SMALLINT UNSIGNED, narrowed across ~50 tables, so there is a hard ceiling of 65,535 hubs. Tracked in 5app/hub#17683.
  • There is no way to move an existing Helix user into a new hub. Someone who already has one keeps it; the alert says so, and it is handled case by case.
  • Whether LinkedIn always sends email_verified is not known. The relay refuses an address only when the claim is explicitly false, because treating an absent claim as a failure would break every login at once if it turned out to be ordinary. A logger.info records each absence; once it is clear the claim is always present, the check can be tightened to refuse anything but true.
  • A handoff token is bound to the browser only by a User-Agent hash. Someone who starts their own login and sends the unredeemed link to a person with the same User-Agent inside 60 seconds logs them into the sender's account. Accepted while trial hubs hold no customer data and anyone can join one by knowing its address; revisit before pointing the relay at anything else.
  • getOrSetUser matches an SSO identity on users.name only, never a validated additional address, so a first SSO login using an alias creates a second account. Pre-existing, affects SAML tenants too, and not specific to the relay.
  • team.mindset_uid is copied as-is, so a cloned team points at the same Mindset knowledge bank as the seed hub's. Consistent with the hub config being copied wholesale, but worth checking before content sync runs from a trial hub.