Roaming Partners
How a guest with an internal account at one tenant can log into a DIFFERENT, partnered tenant's captive portal using their own home credentials — the code-based trust pairing behind it, the identifier@realm login flow, fair usage handling, and exactly what's built today for usage metering.
What Roaming Partners is, and who it's for
Roaming Partners lets a guest who already has an internal account at one tenant ("home") log in at a different, partnered tenant's captive portal ("visited") using their own home username and password — the same basic idea as mobile network roaming, or eduroam for university WiFi. A student from University A visiting University B's campus logs in at B's captive with the login they already have at A, instead of needing a new account created for them at every campus they ever set foot on.
This is built specifically for organizations that already use internal accounts (named logins, not anonymous guest access) and have a real, ongoing relationship with another tenant on the platform — sister campuses, a university consortium, a hospital group with multiple sites each run as their own tenant, a franchise network. It is not a general "any guest from anywhere" mechanism; a visitor only ever roams in if their home institution has a live, mutually-accepted partnership with the network they're standing on.
It is, per this platform's own internal project history, the first tenant-to-tenant trust mechanism ever added here — every other cross-business relationship on the platform (cross-venue ad campaigns, partner revenue-share) either keeps money and data on one side or routes everything through ISN itself. Roaming Partners is the first time two tenants' own guest-identity systems are allowed to trust each other directly.
The code-based pairing model
There is no directory of other tenants on this platform — no searchable list, no "find a partner" page, nothing a tenant (or anyone else) can browse to see who else runs a captive portal here. A pairing only ever starts from a one-time connection code that the other tenant's owner generated themselves and handed over outside the platform entirely (email, phone, however they'd normally talk to that partner).
The server-side code comment on this feature is explicit about why: no directory means a pairing can only ever start from a code "the OTHER tenant's owner proactively generated and shared out-of-band." Structurally, there is no endpoint anywhere in this codebase that lists or searches other tenants' realms or identities — the only way to learn a prospective partner exists is to already know them and ask them directly. That rules out the obvious failure mode of an open directory: a list of every school or venue running this platform, sitting there for anyone to scrape, cold-email, or target.
The actual mechanics, exactly as implemented:
| Item | Details |
|---|---|
Set your own realm first A tenant must set a unique, domain-shaped realm (e.g. yourschool.ac.za | Lowercase letters, numbers, hyphens, at least one dot, enforced by the pattern ^[a-z0-9-]+(\.[a-z0-9-]+)+$, max 100 characters) before it can generate or redeem any connection code. The realm is unique across the whole platform, enforced by a partial unique index at the database level — two tenants can never collide on the same realm. |
Generate a code A 192-bit random hex string (crypto.randomBytes(24) | The same entropy class as every other bearer token this codebase issues), valid for 7 days, single-use. Shared with the prospective partner however the owner chooses. |
Redeem the code The partner pastes it into their own dashboard. This creates a pending partnership row | Redeeming alone is not consent. |
| Explicit accept required Only the tenant that originally generated the code can accept the pending request | The one who redeemed it already expressed consent by redeeming, so the code-generating side has to independently confirm before any login trust actually exists. Only once both of those things have happened does the partnership flip to active. |
A declined partnership isn't a dead end — a fresh code exchange re-opens it back to pending. Every connection-code failure (unknown code, expired, already redeemed, or a tenant trying to redeem its own code) returns the identical generic message, "That code is invalid or has already been used," so a bad guess can't be used to probe which specific reason it failed for.
The guest login flow — identifier@realm
A roaming guest types their normal home username followed by @ and their home institution's realm — for example 22222@theirschool.ac.za — into the same login field every other guest uses. A bare username with no @ never enters this code path at all; it only ever checks the current tenant's own accounts, exactly as before. That branch condition is the entire reason a raw student number can never accidentally be treated as a roaming attempt.
What the server does once it sees the @:
- Splits on the last
@in the string (so a local part that itself contains an@, like an email-style username, still splits correctly), then looks up which tenant owns that realm viatenants.roaming_realm. - If the realm matches the guest's own current tenant (someone redundantly typed their own home suffix), it's treated as an ordinary home login with just the bare username — not roaming.
- If the realm belongs to a genuinely different tenant, the attempt is rate-limited at 5 per hour, keyed by
(home tenant, local part)— deliberately not per visiting IP or per visited captive, so a distributed attempt spread across many different partner captives against one target account still hits one shared budget, not a fresh allowance at each captive it tries. - The partnership between the two tenants is checked — must be
activeand the visited side must currently be accepting incoming visitors (see Pausing, below). No partnership, a declined one, or a currently-paused one all fail identically. - The password is checked against the HOME tenant's own
tenant_internal_accountsrow — this is the one query in the whole feature that deliberately reaches across the tenant boundary, scoped explicitly to the home tenant's id. - The home account must have completed first-login profile setup already, at home, before it can roam anywhere. If it hasn't, the guest is refused at the visited captive with an explicit instruction to finish that step at their home institution first — they are not redirected into a profile-completion flow mid-roam, because that flow writes fields scoped to the account's own home tenant.
- Every failure along this entire chain — no partnership, paused, wrong password, unknown account, unfinished profile — returns the same generic "Invalid credentials" message (the unfinished-profile case is the one deliberate exception, since it's actionable and not a security-sensitive distinction). This stops an attacker from learning anything about whether a given realm, account, or partnership even exists.
Single sign-on (SSO) home institutions: if the home tenant uses SSO instead of passwords, the visitor is bridged to their home tenant's own domain to run the real OIDC/SAML flow there (the redirect URI / RelayState registered with their identity provider only ever works from their own domain), then handed back to the visited captive via a short-lived, single-use token. The visited captive never receives the guest's IdP tokens or session — only confirmation that the account is real, active, and currently valid. A live GET /api/login/realm-check pre-check (identifier typed so far, before the password/IdP step) also re-verifies the partnership and device cap before ever sending a guest through a real Google/Microsoft/Okta consent screen, specifically so nobody goes through a whole external sign-in only to be told "device limit reached" at the very last step.
Why the qualified identifier matters: every in-memory tracking structure in this codebase (active sessions, device counts, usage bundles) is keyed by a bare identifier string, with no separate tenant column the way database rows have. A home login's bare username is already uniquely scoped to one tenant's own accounts table, so it can never collide. Roaming breaks that assumption — two real people at two different home institutions can easily share the same bare username — so every roaming grant tracks usage, devices, and sessions under the full localpart@homerealm string everywhere, never the bare local part. This was an actual bug fixed during development (two students at partnered universities sharing a username would otherwise have shared one session).
Fair usage policy for roaming visitors
Roaming has its own, separate fair-usage override, set on the Roaming Partners settings page itself (not on the general internal-accounts settings page): a throttle threshold in GB and a throttled download/upload speed in Mbps, applied to every visitor from every partner uniformly. Leaving it blank falls back to the tenant's plain tenant-wide fair usage policy, and the override only does anything at all while that tenant-wide policy is switched on — same convention as every other fair-usage override on this platform.
This is a genuinely separate code path from the internal-account fair usage system, and it's worth being specific about why that separation matters: a real bug was found and fixed where internal/SSO accounts stayed throttled forever because they keep one long-lived bundle for their whole account validity window, and nothing ever reset it once it crossed the threshold — that's why internal accounts needed a dedicated reset-mode setting (rolling or calendar-monthly) added afterwards. Roaming was confirmed unaffected by that bug by construction, not by accident: a roaming visitor never gets one long-lived bundle. Every login grants a fresh 24-hour access bundle (see the next section), so there is nothing that needs a periodic "reset" — the grant itself naturally expires and gets reissued the next time the guest logs in.
Fair usage enforcement itself is live, not just login-time: a 5-minute sweep actively re-throttles any bundle — roaming included — that's already over its threshold, and a faster per-request check catches most cases well before that sweep tick runs. A roaming visitor already connected and streaming who crosses their GB threshold mid-session gets throttled without needing to reconnect, the same as any other guest on the platform.
Usage metering during a roaming session — what's actually built
This needs to be stated precisely, because the real behavior has two different layers that are easy to conflate.
The data allowance itself is live-metered, same as any other bundle. Every roaming login grants a genuine 24-hour access bundle through the exact same recordPurchase / recent_bundles machinery every ad-unlock, voucher, or data-plan bundle on this platform uses, tagged with source partner_roaming. Actual data use is tracked against that bundle live via the same router-metrics pipeline (addUsage) every other bundle type relies on, and the bundle genuinely runs out and cuts the guest off once its allotted MB is exhausted, exactly like any other bundle on the platform.
What's sized at login, and only re-checked at login: if the visited tenant has configured a daily/weekly/monthly outsider data cap (see the dashboard fields below) rather than unlimited, the bundle handed out at login is sized to whatever's actually left in the visitor's current period (cap minus usage already recorded this period) — not a flat fresh allowance every time. That period-cap gate, however, is checked only at the moment of a new login, not continuously during an already-running session. The server's own comment on this is direct about the gap: "mid-session live metering/cutoff once a cap is crossed DURING an active session is a documented follow-up, not built here." In practice this means: if a visitor is already connected and partway through their granted bundle, and their period cap gets consumed further in the background (for example by a second device under the same roaming identity), the already-running session is not retroactively cut off mid-stream — but the next fresh login attempt is always correctly re-checked against the current period, so a returning visitor is refused once their aggregate usage has actually caught up to the cap.
One related, purely cosmetic gap worth knowing about: the tenant_roaming_sessions table (the one behind the dashboard's "Outsiders on your network" / "Your students abroad" reports) has an mb_used column that is never actually written to by anything — reading it directly would always show 0. The dashboard reports work around this by reconstructing each session's usage live from the same purchases / recent_bundles data the cap-enforcement logic already reads, matched by qualified identifier and nearest grant time, rather than trusting that dead column. The numbers shown on the reporting tables are accurate; the underlying table column just isn't where they actually come from.
If a tenant sets "Unlimited data for visitors" instead of a capped period, none of the period-cap sizing applies at all — the bundle is simply sized unlimited, and only the device limit, speed caps, and fair-usage override (section 4) govern the visit.
Setting up a partnership, from the dashboard
Everything below lives on the Roaming Partners page of the business dashboard (navigation link "Roaming Partners"). Exact fields, in order:
| Item | Details |
|---|---|
| Your realm | A single text field ("Realm," placeholder e.g. yourschool.ac.za) and a "Save realm" button. Required before anything else on this page works. Once set, it never changes automatically. |
| Connect with a partner | A "Generate a connection code" button (shows the code plus a "Copy" button once generated), and separately, a "Have a code from a partner?" field with a "Connect" button to redeem one you were given. |
| Your partners | A table of every partnership this tenant has, columns: Institution, Status, Their incoming status, and an actions column (accept / decline / pause / resume, depending on current state). |
| Outsider access rules | One set of rules that applies uniformly to a visitor from any of this tenant's partners (not configurable per individual partnership): an "Unlimited data for visitors" checkbox; if left unchecked, a "Data cap (MB)" number field plus a "Cap period" dropdown (Daily / Weekly / Monthly); "Max concurrent devices" (1–50); "Speed cap down (Mbps)" and "Speed cap up (Mbps)" (0 or blank falls back to the tenant's own device-bandwidth default). |
| Fair usage override for visitors (optional) | "Throttle after (GB, blank = tenant default)," "Throttled download (Mbps, blank = tenant default)," "Throttled upload (Mbps, blank = tenant default)," then a "Save outsider settings" button for the whole section. |
| Outsiders on your network | A read-only report table (From / Sessions / Data used (MB)) of guests visiting FROM this tenant's partners, grouped by which partner institution they came from. |
| Your students abroad | The mirror report (Visited / Sessions / Data used (MB)): this tenant's own accounts, seen roaming AWAY at a partner institution. |
Both report tables are aggregate/session-level only — connecting with a partner never hands either side a bulk list of the other's account identities, only session counts and data totals grouped by institution, plus the individual session rows (identifier, device, timestamps) needed to actually attribute that usage.
Edge cases and limits worth knowing about
| Situation | What actually happens |
|---|---|
| Pausing without disconnecting | Either side of an active partnership can pause its own incoming visitors at any time (a_accepts_incoming / b_accepts_incoming, one flag per side) without deleting the partnership row itself. Resuming later needs no fresh code exchange. Each side can always see the other's current accept/pause status on the Roaming Partners page — never silent. |
| Home account hasn't finished profile setup | Refused at the visited captive with an explicit message to finish setup at home first, then come back — never silently redirected into a profile-completion flow at the wrong tenant. |
| Device limit already reached | For a password-based roaming login, refused right at the login screen. For SSO, checked before the guest is ever sent through their home IdP's consent screen, and re-checked again on the way back — so nobody completes a real external sign-in only to be told no at the last step. |
| Rate limiting under attack | 5 attempts/hour, keyed by (home tenant, local part) — shared across every visited captive that account is attacked through, not a fresh budget per captive. |
| Which tenant a realm belongs to is never guessable from outside | No search/listing endpoint for realms or partner identities exists anywhere in this codebase; a realm can only be learned by being told it directly, same as a connection code. |
| A visitor's outsider rules are tenant-wide, not per-partner | The same data cap, device limit, speed caps, and fair-usage override apply to a visitor from any of a tenant's partners — there's no way to give one partner more generous terms than another. |
| Mid-session period-cap enforcement | Not built — see section 5. The allowance granted at login does run out and cut the guest off on its own; what's missing is re-checking the configured daily/weekly/monthly outsider cap continuously while a session is already active. |
