# Portal hub: product visibility, entitlement and subscriptions

> How the portal hub decides which product cards to show, what the org subscription record stores, and where each Manage link points.

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.

| Gate | Where it lives | What it decides |
| ---- | -------------- | --------------- |
| Deployment allowlist (`HUB_PRODUCTS`) | Portal API env | Which products this install serves at all |
| Tenant entitlement claim | Tenant cert, read via `getTenantEntitlement` | Which products this org is entitled to |
| Opt-in modality flag (`optIn` on the card) | Card metadata in the portal | Whether an external card may fall back to "always visible" |
| Per-user product grant | Member grants, via `hasGrant` | Whether *this* signed-in member may open the product |

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:

| Field | Source | Contents |
| ----- | ------ | -------- |
| `org` | Postgres `organization` + `org_member` | `id`, `name`, `createdAt`, `memberCount` (exact count of org members) |
| `verticals` | Per-vertical snapshot helpers | One `VerticalSnapshot` each for `voice`, `robotics`, `games` — status, headline, `cost_mtd_usd`, and the `subscription` record (or `null`) |
| `products` | `buildHubProducts(entitlement)` | The deployment allowlist after entitlement filtering. Each entry has a `key` matching a card's `vertical` |
| `restricted` | `Boolean(env.HUB_PRODUCTS.trim())` | True when this deployment pins an allowlist |
| `recentActivity` | Postgres `public.audit_log` | The five most recent rows for the org |
| `snapshotAt` | Portal API clock | When the snapshot was assembled |

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](#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:

```ts
const deploymentCards = data?.products
  ? CARDS.filter((c) => productByKey.has(c.vertical)
      || (c.external && !restricted && !c.optIn))
  : CARDS.filter((c) => SHOW_MOCK_SPAS || !c.mock);
```

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:

| Field | Effect |
| ----- | ------ |
| `vertical` | The key matched against `products[].key`. Also the key used for subscriptions and grants |
| `external` | No billed `verticals[]` row exists for this product. The subscription and tier UI is skipped and the CTA always opens the console |
| `optIn` | Suppresses the external fallback. Visibility then comes solely from the entitlement-filtered product list |
| `externalLabel` | Pill copy rendered in place of the tier badge on an external card (for example `Operator`) |
| `cta` | CTA label for external cards. Defaults to `Open`; operator surfaces use `Manage` |
| `mock` | Hidden unless `VITE_MOCK_SPAS=1` |
| `section` | `'proof'` places a visible card under the separate *Proof Products* heading instead of the main roster. It changes placement only, never visibility |

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:

```ts
const isGranted = (c: CardMeta) =>
  !grantsLoaded || hasGrant(c.vertical as Modality, 'viewer');
```

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:

| Condition | Meaning |
| --------- | ------- |
| Grants loaded, cards exist for the deployment, none granted | Your org admin has granted you no products. Ask an admin for a grant |
| No cards for the deployment at all | This install serves nothing you can be granted. Nothing you can do in the portal changes it |

## 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:

| Field | Meaning |
| ----- | ------- |
| `tier` | The subscribed tier for this vertical |
| `activated_at` | ISO timestamp of the first activation. Preserved across tier changes |
| `trial_ends_at` | ISO timestamp when the trial ends, or `null` for no trial |

`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:

| Field | Type | Notes |
| ----- | ---- | ----- |
| `orgId` | string | |
| `vertical` | enum of the billable verticals | |
| `tier` | tier enum, nullable | `null` unsubscribes |
| `trialDays` | integer, `0`–`90`, default `30` | `0` means no trial |

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:

```
`${window.location.origin}/${MODALITY_PATH[prefix] ?? prefix}${path}`
```

This is for single-domain on-prem boxes where `<modality>.<brand>`
subdomains do not exist. The prefix-to-path-segment map is:

| Subdomain prefix | Served path segment |
| ---------------- | ------------------- |
| `agent` | `agent` |
| `voiceai` | `voice` |
| `optimus` | `fleet` |
| `arena` | `fleet` |
| `teleop` | `sapienscale` |
| `streams` | `streams` |
| `fabric` | `fabric` |
| `crypto` | `crypto` |
| `realtime` | `realtime` |
| `tunnel` | `tunnel` |
| `quickdesk` | `quickdesk` |
| `vpn` | `vpn` |
| `meet` | `meet` |

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.

## Related

- [Authentication](/concepts/authentication) — API keys, relay tokens and the org scoping the hub queries use
- [Telemetry](/platform/telemetry) — the metric and CDR streams behind the traffic rollups
