The hub is the portal landing page. It renders the product roster (Voice AI, Optimus, Arena, the proof products, and the external consoles), the account state strip, and the CTA on each card. Every card on that screen is the result of four independent gates, and a card that is missing — or present but inert — is almost always one of them. This page documents what each gate is, where it is configured, and which one to check. Subscriptions are a fifth, separate thing. They decide the CTA and the tier badge on a card that is already visible — never visibility itself.

What the hub reads

The hub issues two queries against the TeleQuick portal API. hub.overview({ orgId }) is an org-scoped procedure and returns: If the organization row cannot be read, the procedure throws NOT_FOUND. The hub polls this query on an interval while the page is open. traffic.overview({ orgId, windowMinutes }) backs the state strip and the area chart. The hub calls it with a 1440-minute window. See Account traffic on the hub. Only voice, robotics and games come back as verticals[] rows. The hub derives two headline numbers from them: totalCost (the sum of cost_mtd_usd across all three) and activeCount (how many have a non-null subscription).

Why a product card appears

The card catalogue (CARDS) is static in the portal bundle. The hub filters it:
Read that as two rules:
  1. The product list wins. If hub.overview listed a product whose key matches the card’s vertical, the card is shown.
  2. External cards have a fallback. A card marked external is shown even when the product list does not mention it — but only on an unrestricted deployment (restricted === false) and only when the card is not marked optIn.
Before hub.overview resolves, the hub falls back to the full catalogue minus mock cards. Mock cards are preview surfaces with no real backend; they appear only when the build sets VITE_MOCK_SPAS=1, so a production build never advertises a dummy-data console. The card metadata that participates in this decision: Two consequences are worth spelling out, because both have shipped as bugs:
  • A product served on its own subdomain with no billed vertical row must be external. The non-external CTA branch keys off a verticals[] subscription row, so a non-external card for an unbilled product renders a “Get started” CTA and then calls setSubscription with a vertical that no billing path accepts — the card looks live and does nothing.
  • external alone re-opens the visibility gate, because external cards fall back to always-visible on an unrestricted deployment. Pair it with optIn when the product should only reach entitled orgs.
optIn narrows who sees a card. It never changes where the card’s button points. A card whose manageHref resolves to a host that serves nothing will still render and still 404 on click.

Deployment allowlist vs entitlement claim

These are two different gates that both produce the products array, and they answer different questions. HUB_PRODUCTS is deployment configuration on the portal API. It describes what this install serves. A single-modality on-prem box pins it so the hub hides every other product — including external cards, because a pinned allowlist sets restricted: true and that disables the external fallback. On cloud the variable is empty, so restricted is false and external cards always show. The tenant entitlement claim describes what this org is allowed to use. The portal API loads it with getTenantEntitlement(orgId) and passes it to buildHubProducts, which returns the allowlist filtered by entitlement. Entitlement resolution is deliberately optimistic:
  • getTenantEntitlement is wrapped in .catch(() => null). A lookup failure never fails the hub query.
  • A null entitlement — entitlements not configured, or no cert has landed for the tenant yet — is treated as all entitled.
  • Only an explicit false in claims.modalities[vertical] denies.
The portal is not the enforcement layer. The gateway is. The hub’s job is to avoid advertising a product the tenant cannot use; the gateway’s job is to refuse traffic for it.

Per-user product grants

Deployment and entitlement decide which cards exist for the org. Grants decide which of those cards this member may open. The hub reads them from the tenant context:
Two details matter:
  • Grants are checked at the viewer level, keyed by the card’s vertical.
  • activeGrants === null means not loaded yet, not denied. The hub treats an unloaded grant set as granted so that the first paint does not flash an empty account at a member who has access.
The hub then distinguishes two empty states, because only one of them is actionable by the reader:

Subscriptions, tiers and trials

organization.subscriptions is a JSON column on the org row, keyed by vertical. Each value is either null (not subscribed) or a record: hub.overview reads this column and hands each vertical’s record to its snapshot helper, which returns the card’s status, headline and cost_mtd_usd. A vertical with no record snapshots as status: 'not_subscribed' with a zero month-to-date cost. A subscription record controls the CTA and tier badge on a card. It does not control whether the card appears — that is the allowlist, the entitlement claim and the grant, in that order. Likewise, an external card has no subscription record at all, so its tier badge is replaced by externalLabel and its CTA is always the console link. Note that one plan can back more than one card. The Voice AI card carries the billed voice subscription row; the Contact Center console is a second surface of that same plan and is modelled as an external card with its own vertical key so it does not collide with the billed row in per-card lookups.

Activating and unsubscribing a product

Clicking Get started on a billable card calls hub.setSubscription. It is an org admin procedure — owner or admin only. Plain org membership is not sufficient, because this mutation changes the org’s billing. Input: The mutation runs in this order:
  1. Entitlement gate. When tier !== null, the entitlement is loaded. If claims.modalities[vertical] is explicitly false, the call fails with FORBIDDEN and the message “Your plan does not include the … product. Upgrade to enable it.” A null entitlement is optimistic and does not block.
  2. Read the current record. The org row is fetched; a missing row throws NOT_FOUND.
  3. Compute the next record.
    • tier: null → the vertical’s value becomes null.
    • Same tier as the existing record → the record is returned unchanged, so activated_at and trial_ends_at survive a no-op tier change.
    • Otherwise a new record is written: the supplied tier, activated_at carried over from the previous record if one existed (else now), and trial_ends_at set to now plus trialDays days when trialDays > 0, else null.
  4. Write it back. The new value is merged into the existing subscriptions object; other verticals are untouched. A write failure throws INTERNAL_SERVER_ERROR.
  5. Audit. An audit entry is recorded — action subscribe or unsubscribe, resource type org_subscription, resource id the vertical, with the full before and after records.
The mutation returns { ok: true, subscriptions } with the complete updated map, and the card transitions from Get started to Manage.

Subdomain vs single-host path routing

Each card’s CTA is built by the portal’s subdomain(prefix, path) helper. It has three modes, checked in this order. 1. Single-host path mode. When runtime config sets MODALITY_MODE=path, the link is same-origin:
This is for single-domain on-prem boxes where <modality>.<brand> subdomains do not exist. The prefix-to-path-segment map is: A prefix with no entry falls through to itself. Note that optimus and arena both map to fleet: one bundle serves both the robotics and games verticals. 2. Loopback development. On localhost, 127.0.0.1 or a *.localhost host, the link points at the sibling SPA’s Vite dev server port instead. This mapping exists because the brand-host helper parses the registrable domain off window.location.host with a TLD regex that a trailing :port defeats — without the loopback branch every card in a dev stack would silently link to the production site. The fleet bundle picks its vertical off the hostname, which one loopback origin cannot express, so the robotics and games cards append a ?vertical= override in this mode. 3. Subdomain (default, cloud). https://<prefix>.<brand-host>/<path>. The brand host is the registrable domain of the page the user is currently on, so a card’s Manage button stays on the brand the user arrived from rather than hardcoding one of them. If a card’s CTA 404s, the cause is in this helper or in what the target host serves — not in the visibility gates above.

Recent activity and the audit log

hub.overview returns recentActivity: the five most recent rows of public.audit_log for the org, ordered newest first, selecting ts, actor_label, action, resource_type and resource_id. This is the admin-configuration audit log. Subscription changes land in it directly — hub.setSubscription records an entry on every successful write, with the actor’s user id and email label, so a tier flip or an unsubscribe is attributable after the fact and carries both the before and after subscription records. The hub shows only the newest five rows. It is a recency feed, not a searchable history.

Account traffic on the hub

The state strip and area chart come from traffic.overview, which the hub calls with windowMinutes: 1440. The procedure unions the engine meter rollup with the QUIC sampler rollup in ClickHouse, re-aggregates to one row per modality and 1-minute bucket, and returns:
  • kpi — bytes_in, bytes_out, bytes_total, avg_mbps, peak_mbps, sessions, modalities, window_minutes.
  • byModality — per-modality totals sorted by bytes.
  • series — one point per 1-minute bucket with bytesIn, bytesOut and a byModality breakdown.
Two behaviours affect what you see on the hub: Averages use present buckets. Window seconds are computed from the number of buckets that actually contain data, not the nominal window, so avg_mbps stays honest when traffic spans only part of the window. peak_mbps is the busiest single 1-minute bucket expressed over its 60 seconds. ClickHouse failures soft-empty. If the rollups are unreachable or not yet migrated, the procedure returns the zeroed shape instead of throwing. The hub therefore cannot distinguish “nothing reported in the window” from “the rollup is degraded” — both yield kpi.modalities === 0 — so it renders an em dash rather than a confident 0 B. A degraded rollup costs the hub a dash, never the page. The hub shows the account total only. byModality is present in the response, but its keys are transport subjects (voice_rtp, sip, webrtc, media, wt_media, robot, match_player, …), not the product verticals the cards represent. Several subjects map to more than one product and voice cannot be separated from contact centre at all, so a per-card byte count would be a guess presented as a measurement. The total is exact — the union counts every modality once — and per-product detail belongs on the Traffic screen, which is built for it.
  • Authentication — API keys, relay tokens and the org scoping the hub queries use
  • Telemetry — the metric and CDR streams behind the traffic rollups