npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@inovusmedical/org-switcher

v1.7.0

Published

Organisation switching, seat-based access gating, and role detection for the Totum ecosystem

Readme

@inovusmedical/org-switcher

Organisation switching, seat-based access gating, and role detection for the Totum ecosystem.

Installation

npm install @inovusmedical/org-switcher

Peer Dependencies

The following must already be installed in your application:

  • react ^18 or ^19
  • react-dom ^18 or ^19
  • react-router-dom ^6
  • @supabase/supabase-js ^2
  • org.inovus.unified-auth ^1
  • lucide-react >=0.542.0

Quick Start

Simple org switcher (no seat gating)

import { OrgSwitcherProvider, OrgSwitcher } from '@inovusmedical/org-switcher';
import { AuthProvider } from 'org.inovus.unified-auth';

function App() {
  return (
    <AuthProvider>
      <OrgSwitcherProvider config={{}}>
        <Header>
          <OrgSwitcher />
        </Header>
        <Routes>...</Routes>
      </OrgSwitcherProvider>
    </AuthProvider>
  );
}

Seat-gated app

Add the app's own Stripe product id to your .env — just the one:

VITE_PRODUCT_ID=prod_U4eNgRQiMHpluU

Products that include your app (e.g. a Totum Sim licence that also grants Totum Tasks) are not listed: SeatGate learns them from the verified verify-org-role response (product_inclusions claim), or from the licence builder's product_inclusions table in totum-subs, and accepts a seat on any of them. No Cognito group list is needed either; see Product inclusions and the migration notes.

Then wrap your app:

import { OrgSwitcherProvider, SeatGate, OrgSwitcher } from '@inovusmedical/org-switcher';
import { AuthProvider } from 'org.inovus.unified-auth';

function App() {
  return (
    <AuthProvider>
      <OrgSwitcherProvider
        config={{
          productId: import.meta.env.VITE_PRODUCT_ID,
          // requireSeatGroup: true, // optional layer-3 check: the seat's Cognito group must be on the token
        }}
      >
        <SeatGate>
          <Header>
            <OrgSwitcher />
          </Header>
          <Routes>...</Routes>
        </SeatGate>
      </OrgSwitcherProvider>
    </AuthProvider>
  );
}

API

OrgSwitcherProvider

The main context provider. All shared values (Totum API URL, Supabase connections) are built into the package — only app-specific config is needed.

| Prop | Type | Required | Description | |------|------|----------|-------------| | productId | string | No | The app's own Stripe product id. Enables seat gating; including products come from the inclusion catalogue. A comma-separated list is still accepted as a deprecated fallback (every listed id counts as a seat; Admin rules use only the own product once a catalogue is known). | | cognitoGroup | string | No | Deprecated. Cognito group name(s), comma-separated. Still enforced when set, but a seat that grants the app whose cognito_group_name is on the token satisfies it too (seatInfo.userHasAppGroup). Prefer requireSeatGroup. | | requireSeatGroup | boolean | No | Layer-3 check without a list: the token must carry the cognito_group_name of one of the user's granting seats (default: false). | | productInclusionsFromSubs | boolean | No | When the verified claims carry no product_inclusions, read the subs product_inclusions table as the signed-in user (default: true). | | productInclusionsUrl | string | No | Override that table's PostgREST URL (default: <subscriptionsSupabaseUrl>/rest/v1/product_inclusions) | | adminRoles | string[] | No | Matched against Cognito groups (global admin) and Totum roleName values (org admin). Totum ClientAdmin / SystemAdmin always count as org admins in addition to this list; see AGENT.md | | localStorageKey | string | No | Key for persisting selected group (default: currentGroupGuid) | | cacheTtlMs | number | No | Cache TTL for API responses (default: 10 minutes) | | totumApiUrl | string | No | Override the Totum API URL | | subscriptionsSupabaseUrl | string | No | Override the subscriptions Supabase URL | | subscriptionsSupabaseAnonKey | string | No | Override the subscriptions Supabase anon key | | userPrefsSupabaseUrl | string | No | Override the user prefs Supabase URL | | userPrefsSupabaseAnonKey | string | No | Override the user prefs Supabase anon key | | verifyOrgRole | boolean | No | Call the verify-org-role edge function when an org is selected/switched (default: true). Fail-soft. | | verifyOrgRoleUrl | string | No | Override the edge function URL (default: <subscriptionsSupabaseUrl>/functions/v1/verify-org-role) | | verifyOrgRoleTimeoutMs | number | No | Abandon a verify call after this long (default: 5000) | | orgClaimsJwksUrl | string | No | JWKS used to verify the ES256 signed_claims token (default: <verifyOrgRoleUrl>/.well-known/jwks.json) | | revalidateOnFocus | boolean | No | Re-check groups, roles, verification and seats in the background on window focus / tab visible (default: true; 1.5.0) | | revalidateMinIntervalMs | number | No | Throttle for that re-check when nothing failed and the token is unchanged (default: 30000) |

Tokens, failures and retries (1.5.0)

Every request the package makes (Totum roles, verify-org-role, seat_assignments, user prefs) first obtains a fresh Cognito token — refreshed through unified-auth when the stored one is expired or within a minute of expiry — and, on a 401, refreshes once more and retries. A failed lookup is never shown as "no roles" / "no seats": the previous answer stays in place, the failure is exposed (useGroup().groupsError, seatInfo.error, orgRoleVerification.stale / error), and the package retries with backoff (1 s → 16 s), on window focus / visibility, when the token changes, and on useGroup().revalidateAccess(). Apps need no recovery code of their own (widgets-dashboard's useOrgAccessRecovery becomes redundant). See CHANGELOG.md.

Role layers

Three independent layers are all honoured:

  1. Cognito — User or Admin (Admin is Inovus-only). Exposed as useIsAdmin().isGlobalAdmin via adminRoles.
  2. Totum role — SystemAdmin, ClientAdmin or a client user, fetched from the Totum API by totumGroupService. ClientAdmin/SystemAdmin always make useIsAdmin().isGroupAdmin (and isTotumAdmin) true for that org. The Manager roles (ClientManager, SystemManager) are a separate, narrower scope exposed as useIsAdmin().isOrgManager / canManageOrg — only from the signed verify-org-role claims (1.7.0), never from the role name.
  3. App roles — a seat_assignments row plus the matching cognito_groups group. Any seat row counts as a seat for SeatGate, whatever its role_type (Member, Standard, User, Reviewer, …). Only role_type Admin (case-insensitive) is an admin seat.

verify-org-role

Whenever an org is selected or switched (including the remembered org on first load), the provider POSTs the user's Cognito token (Authorization: Bearer, no body) to the verify-org-role Supabase edge function before reading seat_assignments. The function checks the user's Totum role for every org at once and caches verified ClientAdmin/SystemAdmin rows that row-level security relies on. It answers 200 { system_admin, admin_orgs, expires_at }, or { error } with 401 (bad token), 502 (Totum API unavailable) or 500 (config).

Because one call covers all orgs, the result is cached per token until expires_at (falling back to cacheTtlMs); switching orgs only re-calls the function when the cache is missing or expired, so there is no per-switch delay.

The call is non-blocking and fail-soft: timeouts, network errors and non-2xx responses are logged with console.warn and the app continues. Login and org switching never depend on it. The last result is exposed as useGroup().orgRoleVerification.

Verified seats and signed org claims (1.2.0+)

Newer versions of the edge function also return signed_claims: a short-lived JWT signed with ES256 (kid in the header) carrying iss: "verify-org-role", sub (the user's Cognito sub), system_admin, admin_orgs, seats ({ group_guid, stripe_product_id, role_type, is_admin, subscription_status, subscription_active }[] for every seat the user holds, across all orgs), iat and exp — plus, from claims_version: 2, system_manager and manager_orgs (see "Verified Manager role" below). The function publishes its public keys at <verifyOrgRoleUrl>/.well-known/jwks.json (default project nalmtqrfthdqjvsawzwk; override with orgClaimsJwksUrl).

Every check happens inside this package; there is no per-app secret. Before anything from the token is exposed, the client verifies it with verifyOrgClaims — the same function the server entry uses — checking the ES256 signature against the JWKS (fetched once, cached by kid, refetched once on an unknown kid so key rotation needs no reload), iss, exp (60 s clock skew) and that sub matches the signed-in user. Only a token that passes is exposed as orgRoleVerification.signedClaims / getSignedOrgClaims(), and seats always come from the verified token (a plain seats array in the body is never trusted). A rejected token leaves seats: [], signedClaims: null and sets claimsStatus: 'failed' with a claimsError; the verification itself stays verified (admin flags still apply) and a console.warn explains why.

All of this is fail-soft: an older function that omits signed_claims still verifies normally (seats: [], claimsStatus: 'missing'). The token is cached with the per-token verification cache; the entry expires at the earlier of expires_at and the claims' exp, so an expired token is never handed out. Seat role_type is free-form; only Admin (any case) is an admin seat (is_admin is derived from role_type).

import { useVerifiedSeats, getSignedOrgClaims, getVerifiedOrgClaims, createOrgClaimsSupabaseClient } from '@inovusmedical/org-switcher';

// Seats for the current org from the *verified* token (scoped to a product, or to the configured productId list).
const { hasSeat, hasActiveSeat, hasAdminSeat, seats, claimsStatus } = useVerifiedSeats('prod_ABC');

// Latest verified token / claim set, or null when nothing fresh is cached (sync, no network).
const token = getSignedOrgClaims();
const claims = getVerifiedOrgClaims();

// A Supabase client for the app's own project that attaches `x-org-role-claims`
// (and the Cognito bearer token) to every request, refreshing the token as it expires.
// Verify it on the receiving side with `@inovusmedical/org-switcher/server`.
export const appSupabase = createOrgClaimsSupabaseClient(APP_SUPABASE_URL, APP_SUPABASE_ANON_KEY);

SeatGate is unchanged: it still decides from seat_assignments rows and Cognito groups, not from signed_claims.

Verified Manager role (1.7.0+, claims_version: 2)

totum-subs verify-org-role now also signs the user's Totum Manager scope, as an additive v2 of the signed_claims contract:

| Claim | Type | Absent (v1) | Meaning | |-------|------|-------------|---------| | claims_version | number | 1 | Contract version. 2 once the manager fields are signed. | | system_manager | boolean | false | The user holds a system-level SystemManager assignment → manager of every org. | | manager_orgs | string[] | [] | group_guids where the user is a ClientManager, or holds a group-level SystemManager assignment. |

The scope is derived by the signer from the authoritative Totum role assignment × instance; this package never infers it from a role name, never reads it from the unsigned response body, and never folds managers into system_admin / admin_orgs. verifyOrgClaims normalises every token to one shape — a v1 token yields claims_version: 1, system_manager: false, manager_orgs: [] — and honours the manager fields only when claims_version >= 2, so an unsigned-contract field grants nothing. Every v1 claim keeps its name, type and derivation.

import { useIsAdmin, useGroup, getVerifiedOrgClaims, isOrgManager, canManageOrg } from '@inovusmedical/org-switcher';

const { canManage, isOrgManager: manager, canManageOrg: canEdit } = useIsAdmin();
// canManage     — unchanged: Cognito Admin / Totum ClientAdmin / SystemAdmin (licences, seats, billing, AdminRoute)
// isOrgManager  — the *verified* v2 token names the current org in manager_orgs, or carries system_manager
// canManageOrg  — canManage || isOrgManager: the common content-management predicate for Tasks / widgets

const { systemManager, managerOrgs, claimsVersion } = useGroup().orgRoleVerification; // verified token only

// Non-hook: the same predicates over the verified claim set (null when nothing fresh is cached).
const claims = getVerifiedOrgClaims();
const ok = claims ? canManageOrg(claims, orgId) : false;

Fail-closed: isOrgManager is false while nothing is verified, against a v1 / missing / expired / tampered / wrong-key / wrong-user token, for orgs not in manager_orgs, and for an admin who holds no manager scope. It is recomputed from the cached verification on every org switch. Managers gain nothing else: isGroupAdmin, isTotumAdmin, canManage, AdminRoute, SeatGate, useVerifiedSeats().hasAdminSeat, isOrgAdmin and isAppAdmin are unchanged.

Server-side seat check (@inovusmedical/org-switcher/server)

A React-free entry point for backends that receive x-org-role-claims: Supabase edge functions (Deno), Cloudflare Workers, Node 18+, Bun. Its only dependency is jose (Web Crypto). It verifies the token against the public JWKS — no shared secret to distribute — and gives you typed claims plus seat/admin predicates.

import {
  verifyOrgClaims,          // (token, { jwksUrl?, expectedSub?, clockToleranceSec?, fetch?, requestInit? }?) => Promise<OrgClaims>
  getOrgClaimsFromRequest,  // (req, options?) => Promise<OrgClaims> — reads x-org-role-claims; throws OrgClaimsError
  hasSeat,                  // (claims, appKey, seatType?, { orgId?, includeInactive?, inclusions? }?) => boolean
  isOrgAdmin,               // (claims, orgId) => boolean — Totum ClientAdmin of that org or SystemAdmin; no seat, no subscription involved; managers do not count
  isOrgManager,             // (claims, orgId) => boolean — Totum ClientManager of that org or system-level SystemManager (verified v2 claims); admins do not count (1.7.0)
  canManageOrg,             // (claims, orgId) => boolean — isOrgAdmin || isOrgManager: the shared content-management predicate (1.7.0)
  isAppAdmin,               // (claims, appKey, { orgId? }?) => boolean — an "Admin" seat for that product only whose subscription still grants admin
  getAppSeats,              // (claims, appKey, { orgId?, inclusions? }?) => VerifiedSeat[]
  resolveAppProducts, ownAppProducts, fetchProductInclusions, parseProductInclusions, // product inclusions (1.4.0, below)
  isAdminSeatSubscriptionActive, // (seat) => boolean — the subscription rule behind isAppAdmin
  isAdminGrantingSeat,      // (seat) => boolean — Admin role_type AND isAdminSeatSubscriptionActive
  ADMIN_SEAT_SUBSCRIPTION_STATUSES, // ['active', 'trialing', 'paused', 'past_due']
  OrgClaimsError,           // .code: 'missing' | 'malformed' | 'unsupported_alg' | 'jwks_unavailable' | 'unknown_kid'
                            //        | 'invalid_signature' | 'expired' | 'not_yet_valid' | 'invalid_issuer' | 'invalid_subject' | 'invalid_claims'
  TOTUM_ORG_CLAIMS_HEADER,  // 'x-org-role-claims'
  DEFAULT_ORG_CLAIMS_JWKS_URL, buildOrgClaimsJwksUrl,
  getOrgMemberSeats, membersHoldSeat, memberHoldsSeat, OrgMemberSeatsError, // other members' seats, read from totum-subs as the caller (1.3.0+, below)
} from '@inovusmedical/org-switcher/server';
  • appKey is the app's Stripe product id (or a list of them), matched exactly against stripe_product_id — widened (1.4.0) to every product whose seat includes the app, from the token's product_inclusions claim or an explicit catalogue (see "Product inclusions" below). Admin checks use only the app's own product.
  • hasSeat counts any seat row whatever its free-form role_type (pass seatType to require one, matched case-insensitively — the package never hard-codes seat type names other than Admin). Only seats with subscription_active count unless includeInactive: true.
  • isAppAdmin matches role_type === "Admin" (any case) for that product only, and since 1.2.1 the seat's subscription must still grant admin rights. The rule (isAdminSeatSubscriptionActive) reads the seat's subscription_status / subscription_active from the verified claims:
    • subscription_status ∈ ADMIN_SEAT_SUBSCRIPTION_STATUSES = active, trialing, paused, past_due (case-insensitive) → admin, even when the server says subscription_active: false, so admins can still manage a paused, trialling or past-due subscription. past_due is deliberately included: Stripe keeps the subscription in service while it retries payment.
    • any other status — canceled / cancelled, expired, incomplete, incomplete_expired, unpaid, ended or anything unknown — → not admin, whatever subscription_active says.
    • subscription_status: null (older tokens) → falls back to subscription_active.
  • isOrgAdmin is the separate Totum layer (admin_orgs / system_admin): it needs no seat and never looks at subscriptions. Combine them as your app needs: isOrgAdmin(claims, orgId) || isAppAdmin(claims, PRODUCT_ID, { orgId }).
  • isOrgManager (1.7.0) reads only the verified v2 system_manager / manager_orgs claims (false for any v1 token, and for admins without manager scope); canManageOrg = isOrgAdmin || isOrgManager. Use canManageOrg for content management and keep isOrgAdmin / isAppAdmin for licence, seat and billing administration — managers never satisfy those. claims.claims_version tells you which contract the token was signed under.
  • Pass expectedSub (the Cognito sub from the request's own bearer token) so a token cannot be replayed for another user.

Supabase edge function (Deno):

// supabase/functions/cases/index.ts
import { getOrgClaimsFromRequest, hasSeat, isAppAdmin, isOrgAdmin, canManageOrg, OrgClaimsError } from 'npm:@inovusmedical/org-switcher/server';
import { decodeJwt } from 'npm:jose';

const PRODUCT_ID = Deno.env.get('APP_PRODUCT_ID')!;

Deno.serve(async (req) => {
  // The user's own Cognito token (verify it as you already do); its `sub` must match the claims.
  const cognitoSub = decodeJwt(req.headers.get('Authorization')!.slice(7)).sub;
  const orgId = new URL(req.url).searchParams.get('org');

  let claims;
  try {
    claims = await getOrgClaimsFromRequest(req, { expectedSub: cognitoSub });
  } catch (e) {
    const code = e instanceof OrgClaimsError ? e.code : 'error';
    return Response.json({ error: `org claims ${code}` }, { status: 401 });
  }

  if (!hasSeat(claims, PRODUCT_ID, undefined, { orgId })) {
    return Response.json({ error: 'No seat for this org' }, { status: 403 });
  }
  // Licence / seat / billing administration: admins only (Totum or app Admin seat).
  const canAdminister = isOrgAdmin(claims, orgId) || isAppAdmin(claims, PRODUCT_ID, { orgId });
  // Day-to-day content management: Totum admin OR verified manager of this org (1.7.0).
  const canManage = canManageOrg(claims, orgId);

  // ... use claims.sub, orgId, canAdminister and canManage with the service-role client
  return Response.json({ ok: true, canAdminister, canManage });
});

Cloudflare Worker:

import { getOrgClaimsFromRequest, hasSeat, isAppAdmin, OrgClaimsError } from '@inovusmedical/org-switcher/server';

export default {
  async fetch(req: Request, env: { APP_PRODUCT_ID: string; ORG_CLAIMS_JWKS_URL?: string }) {
    let claims;
    try {
      claims = await getOrgClaimsFromRequest(req, {
        jwksUrl: env.ORG_CLAIMS_JWKS_URL, // optional: defaults to the Totum subscriptions project
        expectedSub: await resolveCognitoSub(req), // your existing bearer-token check
      });
    } catch (e) {
      return new Response(e instanceof OrgClaimsError ? e.message : 'Unauthorized', { status: 401 });
    }

    const orgId = req.headers.get('x-org-id');
    if (!hasSeat(claims, env.APP_PRODUCT_ID, 'Reviewer', { orgId })) return new Response('Forbidden', { status: 403 });
    if (new URL(req.url).pathname.startsWith('/admin') && !isAppAdmin(claims, env.APP_PRODUCT_ID, { orgId })) {
      return new Response('Admins only', { status: 403 });
    }
    return Response.json({ user: claims.sub });
  },
};

The JWKS is fetched lazily on the first verification in each isolate/process and cached in memory by kid; an unknown kid triggers one refetch (rate-limited to once per 30 s) so key rotation on totum-subs needs no redeploy. Pass fetch / requestInit to customise the JWKS request, clockToleranceSec to widen the default 60 s skew, and clearOrgClaimsKeyCache() to drop cached keys. The CommonJS build (dist/server.cjs) requires Node ≥ 20.19 / 22.12 because jose v6 is ESM-only; ESM consumers have no such constraint.

Seats of other org members (1.3.0+) — getOrgMemberSeats / membersHoldSeat

x-org-role-claims only describes the caller. When a backend must check that other members hold a seat — e.g. Tasks' curriculum assign checking every assignee — use these helpers instead of talking to totum-subs yourself. Apps never hard-code the subs URL or key: the package carries the nalmtqrfthdqjvsawzwk project URL and its public anon key (DEFAULT_SUBSCRIPTIONS_SUPABASE_URL / DEFAULT_SUBSCRIPTIONS_SUPABASE_ANON_KEY), overridable per call for staging.

import { getOrgMemberSeats, membersHoldSeat, memberHoldsSeat, OrgMemberSeatsError } from '@inovusmedical/org-switcher/server';

// The caller's own Cognito access token (from the incoming Authorization header) — never a service-role key.
const callerToken = req.headers.get('Authorization')!.slice(7);

const check = await membersHoldSeat({
  orgId,                       // Totum group_guid
  userIds: assigneeSubs,       // Cognito subs of the members to check
  appKey: APP_PRODUCT_ID,      // Stripe product id(s); omit for any product
  callerToken,
  // seatType?: 'Reviewer'     // free-form role_type (case-insensitive); only 'Admin' is special
  // includeInactive?: boolean // also count seats whose subscription is not active
  // inclusions?: 'subs' (default) | ProductInclusions | resolver | false // products that include the app also count (1.4.0, below)
  // subscriptionsSupabaseUrl?, subscriptionsSupabaseAnonKey?, verifyOrgRoleUrl?, seatAssignmentsUrl?, productInclusionsUrl?, fetch?, timeoutMs?, signal?
});

if (!check.all) {
  // check.missing: ids without a seat *visible to the caller*; check.holders / check.byUser for the rest
  const reason = check.caller.orgAdmin ? 'no seat' : 'not visible to a non-admin caller';
  return Response.json({ error: `Assignees without a seat (${reason})`, missing: check.missing }, { status: 403 });
}

// Lower level: the normalised rows themselves (+ seatsByUser, caller) — same shape as VerifiedSeat plus user_guid / user_name / user_email / row.
const { seats, seatsByUser, caller } = await getOrgMemberSeats({ orgId, userIds: assigneeSubs, appKey: APP_PRODUCT_ID, callerToken });
memberHoldsSeat(seats, assigneeSubs[0], { appKey: APP_PRODUCT_ID, seatType: 'Admin' }); // pure, reusable on cached data

How it works — the same two requests the client org switcher makes when it loads an org's seats, with the caller's token as the bearer:

  1. POST <subs>/functions/v1/verify-org-role (fail-soft) so totum-subs' RLS sees the caller's verified Totum role. The answer is reported as caller: { sub, verification: 'verified' | 'failed' | 'skipped', systemAdmin, orgAdmin, error }; skip it with warmRoleCache: false.
  2. GET <subs>/rest/v1/seat_assignments?select=*&group_guid=eq.<orgId>&user_guid=in.(…)[&stripe_product_id=in.(…)] with apikey: <anon key> + Authorization: Bearer <callerToken>. Long userIds lists are split into requests of 50.

RLS decides what is visible: a Totum ClientAdmin of the org or a SystemAdmin sees the org's seats; a regular member only their own. So a member in missing either has no seat or is not visible to this caller — read caller.orgAdmin to tell the two apart. There is no service-role key, no shared secret and no new subs endpoint.

Rules (shared with hasSeat / isAppAdmin): any role_type counts unless seatType narrows it (case-insensitive; no seat type names other than Admin are hard-coded); seatType: 'Admin' applies the admin-granting subscription rule (active / trialing / paused / past_due); only subscription_active seats count unless includeInactive. seat_assignments rows carry no subscription_status / subscription_active today, so those fields are null / true (a row exists only while the seat is assigned — the rule SeatGate already applies); if totum-subs adds them to the read, the status-aware rules take effect automatically.

Errors are thrown as OrgMemberSeatsError with code: 'invalid_options' | 'unauthorized' | 'http_error' | 'invalid_response' | 'timeout' | 'aborted' | 'network' (plus status / body). The helpers are also exported from the main entry.

Product inclusions (1.4.0) — a seat on a product that includes the app

Some licences grant more than their own app: a Totum Sim seat also grants Totum Tasks, without an individual Tasks seat. Every "does this user hold a seat for this app" decision in the package goes through one resolver, resolveAppProducts(appKey, inclusions): the app's own product id(s) plus every product the inclusion catalogue says includes them (transitively — a bundle that includes Sim includes Tasks). Used by SeatGate (via seatInfo.appProducts), hasSeat / getAppSeats, memberHoldsSeat / membersHoldSeat / getOrgMemberSeats, selectVerifiedSeats / useVerifiedSeats and subscriptionService.getProductSeatAssignments.

No product mapping is hard-coded and no env list is required. The catalogue is a ProductInclusions map — { [stripe_product_id]: included_product_id[] }, e.g. { prod_SIM: ['prod_TASKS'] } — maintained in totum-subs' licence builder and read from data:

| Source | Where it applies | How | |--------|------------------|-----| | product_inclusions claim in the verified signed_claims token | SeatGate / seatInfo, useVerifiedSeats, hasSeat / getAppSeats (server and browser) | automatic (OrgClaims.product_inclusions); preferred source | | totum-subs product_inclusions table, read as the caller | SeatGate / seatInfo when the claims carry none (productInclusionsFromSubs, default on); getOrgMemberSeats / membersHoldSeat by default (inclusions: 'subs'); fetchProductInclusions({ callerToken }) anywhere | GET <subs>/rest/v1/product_inclusions?select=stripe_product_id,included_product_id with the anon apikey + the caller's bearer, cached 5 min per URL; fail-soft | | an explicit map | any predicate: hasSeat(claims, APP, undefined, { inclusions }), membersHoldSeat({ …, inclusions }), memberHoldsSeat(seats, id, { inclusions }) | for tests or a custom source; inclusions: null / false forces the exact product |

seatInfo tells you what the gate decided with: appProducts (the resolved product list), productInclusions, inclusionsSource: 'claims' | 'subs' | 'none'.

import { membersHoldSeat, hasSeat, fetchProductInclusions, resolveAppProducts, ownAppProducts } from '@inovusmedical/org-switcher/server';

// Tasks' assign check: Sim / bundle holders count as Tasks seat holders — the catalogue is read from subs by default.
const check = await membersHoldSeat({ orgId, userIds: assigneeSubs, appKey: TASKS_PRODUCT_ID, callerToken });
check.inclusions; // { source: 'none' | 'option' | 'subs' | 'resolver', map, products, error } — which products counted and why

// The caller's own seat: the product_inclusions claim is applied automatically; override or disable per call.
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId });                      // claim-driven
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId, inclusions: null });    // exact product only
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId, inclusions: await fetchProductInclusions({ callerToken }) });

resolveAppProducts('prod_TASKS', { prod_SIM: ['prod_TASKS'], prod_BUNDLE: ['prod_SIM'] }); // ['prod_TASKS', 'prod_SIM', 'prod_BUNDLE']
ownAppProducts(['prod_TASKS', 'prod_SIM'], { prod_SIM: ['prod_TASKS'] });                   // ['prod_TASKS'] — the Admin scope

Inside getOrgMemberSeats the widened list also goes into the PostgREST stripe_product_id=in.(…) filter, so the including products' rows are actually fetched. When no catalogue is available (no claim, table not deployed yet → 404, reported on result.inclusions.error / seatInfo.inclusionsSource: 'none') every predicate falls back to the exact stripe_product_id match.

Admin stays per-product — behaviour change in 1.4.0. isAppAdmin, hasSeat(…, 'Admin'), memberHoldsSeat / membersHoldSeat with seatType: 'Admin', useVerifiedSeats().adminSeats / hasAdminSeat and subscriptionService.getAdminSeats only look at the app's own product: a Sim Admin seat is a Sim admin, not a Tasks admin. This now holds even when the app passes several product ids (appKey: [TASKS, SIM], or the deprecated multi-value productId): with a catalogue, ownAppProducts drops every listed product that merely includes another listed one before the Admin seats are looked up. Before 1.4.0 such a list made a Sim Admin an app admin. Without any catalogue the list is still taken at face value. Seat checks (hasSeat, SeatGate) are not affected — a Sim seat still is a Tasks seat.

Layer 3 (seat + app Cognito group) without a list. Each seat_assignments row records the cognito_group_name the licence builder put the holder in. seatInfo.userHasAppGroup is true when one of the user's granting seats has that group on the token. SeatGate enforces it with requireSeatGroup: true, and accepts it in place of the deprecated cognitoGroup list, so an included-app holder passes a Tasks gate that still lists only Tasks' groups. For included apps no group check is strictly needed beyond this: the Sim holder is in Sim's group, which is exactly what the row says — there is no Tasks group to require.

What totum-subs provides (being added alongside this release): the licence builder's "also grants access to" field persisted as public.product_inclusions (stripe_product_id text, included_product_id text, primary key (stripe_product_id, included_product_id)), readable by signed-in users, and the same catalogue in verify-org-role's body and signed_claims payload as product_inclusions: { [stripe_product_id]: included_product_id[] }. verifyOrgClaims parses it leniently (parseProductInclusions; an array of { stripe_product_id, included_product_id } rows is accepted too).

Migrating from env lists (1.3.0 and earlier)

Before 1.4.0 an app that could be reached through other licences had to enumerate every granting product and every Cognito group in its env (Tasks listed seven product ids and seven prod_…_standard groups), and its backend had to pass the same list to membersHoldSeat. Now:

| Before | After | |--------|-------| | productId: 'prod_TASKS,prod_SIM,prod_BUNDLE,…' | productId: 'prod_TASKS' — the app's own product only; Sim and the bundle come from the catalogue | | cognitoGroup: 'prod_tasks_standard,prod_sim_standard,…' | drop it; use requireSeatGroup: true if you want the layer-3 group check (no list) | | membersHoldSeat({ appKey: [TASKS, SIM, …] }) / hasSeat(claims, [TASKS, SIM, …]) | appKey: TASKS_PRODUCT_ID — inclusions are applied from the claim / subs by default | | isAppAdmin(claims, [TASKS, SIM, …]) treated a Sim Admin as a Tasks admin | isAppAdmin(claims, TASKS_PRODUCT_ID); with a catalogue the old list no longer grants admin via Sim either (see above) | | useVerifiedSeats() with the configured list | unchanged call; inclusions applied, adminSeats on the own product |

Nothing breaks if you do not migrate: the multi-value productId, the cognitoGroup list and multi-id appKeys keep working as deprecated fallbacks (the gate also recognises a seat whose own group is on the token, so holders of newly included products pass without the list being extended). The only observable difference is the Admin rule above. Both lists will be removed in a later major version.

Hooks

useGroup()

const {
  currentGroup,      // GroupMembership | null
  groups,            // GroupMembership[]
  isLoading,         // boolean
  hasFetchedOnce,    // boolean
  switchGroup,       // (groupGuid: string) => void
  refreshGroups,     // () => Promise<void>
  defaultGroupGuid,  // string | null — user's persisted default org
  setDefaultGroup,   // (groupGuid: string) => Promise<void>
  seatInfo,          // GroupSeatInfo
  refreshSeatInfo,   // () => Promise<void>
  orgRoleVerification, // OrgRoleVerification — last verify-org-role outcome
  groupsError,       // string | null — why the last group/role load failed (previous groups kept) (1.5.0)
  isRevalidating,    // boolean — a background re-check is running or a retry is scheduled (1.5.0)
  revalidateAccess,  // () => Promise<void> — re-check everything now, keeping current answers (1.5.0)
} = useGroup();

GroupMembership:

{
  id: string;
  group_guid: string;
  group_name: string;
  totumRoles: { roleId: string; roleName: string }[];
}

GroupSeatInfo:

{
  groupHasSubscription: boolean;  // does the org have seats for the product?
  userHasSeat: boolean;           // does the current user have a seat?
  userSeats: SeatAssignment[];    // the user's seats in this org
  groupSeats: SeatAssignment[];   // all seats for this org
  isFetchingSeats: boolean;
  appProducts: string[];          // own product(s) + products that include them (1.4.0)
  productInclusions: ProductInclusions | null;
  inclusionsSource: 'claims' | 'subs' | 'none';
  userHasAppGroup: boolean;
  groupGuid: string | null;       // the org these seats belong to (1.5.0)
  error: string | null;           // why the last seat read failed; previous answer kept, retry scheduled (1.5.0)
}

OrgRoleVerification:

{
  status: 'idle' | 'pending' | 'verified' | 'failed' | 'skipped';
  systemAdmin: boolean;      // Totum SystemAdmin (admin of every org)
  adminOrgs: string[];       // group_guids where the user is ClientAdmin
  systemManager: boolean;    // system-level Totum SystemManager, from the *verified* v2 signed_claims only (1.7.0)
  managerOrgs: string[];     // group_guids where the user is ClientManager / group-level SystemManager, verified v2 claims only (1.7.0)
  claimsVersion: number | null; // claims_version of the verified token (1 or 2), null without one (1.7.0)
  seats: VerifiedSeat[];     // seats across every org, from the *verified* signed_claims only ([] when missing/rejected)
  signedClaims: string | null;          // verified ES256 JWT for the x-org-role-claims header (null when absent/expired/rejected)
  signedClaimsExpiresAt: string | null; // ISO expiry from the verified exp
  claims: OrgClaims | null;             // the verified claim set { iss, sub, claims_version, system_admin, admin_orgs, system_manager, manager_orgs, seats, product_inclusions?, iat, exp }
  claimsStatus: 'missing' | 'verified' | 'failed';
  claimsError: string | null;           // why the token was rejected, when claimsStatus is 'failed'
  expiresAt: string | null;  // server-provided cache expiry
  fromCache: boolean;        // served from memory, no network call
  httpStatus: number | null; // 200 / 401 / 502 / 500 when a response was received
  error: string | null;      // reason when failed/skipped (or why the last re-verification failed, when stale)
  stale: boolean;            // verified answer kept after a failed re-verification (1.5.0)
  isRevalidating: boolean;   // re-verifying behind a verified answer, no 'pending' flicker (1.5.0)
}

VerifiedSeat:

{
  group_guid: string;
  stripe_product_id: string;
  role_type: string;                  // free-form (Admin, Standard, Reviewer, …)
  is_admin: boolean;                  // true only when role_type is "Admin" (any case)
  subscription_status: string | null; // Stripe status: "active", "trialing", "paused", "past_due", "canceled", "unpaid", … or null
  subscription_active: boolean;       // server's verdict for regular access (hasSeat / activeSeats)
}

useVerifiedSeats(productId?)

const {
  status,        // mirrors orgRoleVerification.status
  isVerifying,   // status === 'pending'
  claimsStatus,  // 'missing' | 'verified' | 'failed' — whether the signed token behind `seats` verified
  claimsError,   // reason when claimsStatus is 'failed'
  seats,         // VerifiedSeat[] for the current org (+ product scope), from the verified token only
  activeSeats,   // seats with subscription_active
  adminSeats,    // role_type "Admin" (any case) AND subscription active / trialing / paused / past_due (same rule as isAppAdmin)
  hasSeat,       // seats.length > 0 — any role type counts
  hasActiveSeat,
  hasAdminSeat,  // adminSeats.length > 0 — a cancelled / expired / unpaid Admin seat does not count
  allSeats,      // every verified seat, all orgs, unfiltered
} = useVerifiedSeats('prod_ABC'); // omit productId to use the configured productId list (or every product when none)

Everything is empty until a verification succeeds and the signed_claims token passes verifyOrgClaims (claimsStatus === 'verified'); it stays empty with an older edge function or a rejected token. The org guid is matched case-insensitively; product ids exactly — plus, when the token carries a product_inclusions claim, seats on products that include the app (seats / activeSeats / hasSeat / hasActiveSeat; adminSeats / hasAdminSeat stay on the exact product — see "Product inclusions").

getSignedOrgClaims() / ensureSignedOrgClaims() / createOrgClaimsSupabaseClient()

import {
  getSignedOrgClaims,            // (): string | null — sync, cached, verified token or null when nothing fresh
  getVerifiedOrgClaims,          // (): OrgClaims | null — the verified claim set behind it
  ensureSignedOrgClaims,         // ({ force? }?): Promise<string | null> — re-verifies when stale
  getOrgClaimsHeaders,           // (): Promise<{ 'x-org-role-claims': string } | {}>
  withOrgClaimsFetch,            // ({ cognitoBearer?, fetch? }?): typeof fetch
  createOrgClaimsSupabaseClient, // (url, anonKey, { cognitoBearer?, fetch?, supabase? }?) => SupabaseClient
  TOTUM_ORG_CLAIMS_HEADER,       // 'x-org-role-claims'
} from '@inovusmedical/org-switcher';

// Recommended: one client per app, created at module scope.
export const appSupabase = createOrgClaimsSupabaseClient(
  import.meta.env.VITE_APP_SUPABASE_URL,
  import.meta.env.VITE_APP_SUPABASE_ANON_KEY,
  // { cognitoBearer: false } if the app signs in with Supabase Auth instead of the Cognito token
);

// Manual alternative for a raw fetch to an edge function:
await fetch(url, { headers: { ...(await getOrgClaimsHeaders()), apikey: ANON_KEY } });

The wrapper reads the token from the per-token cache and only calls verify-org-role when nothing fresh is cached, so it adds no round-trip to normal requests. When no verified token is available (older function, verifyOrgRole: false, failed or rejected verification, logged out) the request is sent without the header and the receiving side decides (typically getOrgClaimsFromRequest throwing missing → 401).

useIsAdmin()

const {
  isGlobalAdmin,   // boolean — Cognito groups (Inovus Admin, LMS admin group, …)
  isGroupAdmin,    // boolean — Totum ClientAdmin/SystemAdmin or adminRoles match on currentGroup
  isTotumAdmin,    // boolean — ClientAdmin/SystemAdmin only (Totum API or verify-org-role)
  canManage,       // boolean — isGlobalAdmin || isGroupAdmin (admin authority: licences, seats, billing, AdminRoute)
  isOrgManager,    // boolean — verified v2 signed_claims name the current org in manager_orgs, or carry system_manager (1.7.0)
  canManageOrg,    // boolean — canManage || isOrgManager (content management; grants no seat / billing rights) (1.7.0)
} = useIsAdmin();

isOrgManager is never derived from Totum role names or from the unsigned verify-org-role body: only the signed token the package verified counts, and it is re-evaluated against the current org on every switch. A manager-only user has canManage: false, so AdminRoute and every licence / seat / billing affordance stay admin-only.

Components

SeatGate

Blocks the app until the user holds a seat for it in the current org. Automatically disabled when neither productId nor cognitoGroup are configured.

The decision is data-driven (1.4.0): with productId set to the app's own product, the gate requires a seat_assignments row for the current org on that product or on any product that includes it (seatInfo.appProducts, from the verified product_inclusions claim, else the subs product_inclusions table). Optional layer-3 check: requireSeatGroup: true also demands that the granting seat's cognito_group_name is on the token (seatInfo.userHasAppGroup). The deprecated cognitoGroup list is still enforced when set, but that seat-group match satisfies it. Verified seats claims never replace the row read: seat presence always comes from seat_assignments. See Product inclusions.

<SeatGate
  loadingComponent={<YourSpinner />}
  noAccessComponent={<YourCustomScreen />}
>
  <Routes>...</Routes>
</SeatGate>

The built-in no-access screen includes the org switcher so users can switch to an org with valid seats.

OrgSwitcher

Dropdown for switching between organisations. Shows role badges with hierarchy sorting.

<OrgSwitcher
  variant="header"          // "header" | "sidebar" | "minimal"
  dropdownAlign="right"     // "right" | "left"
  searchThreshold={8}       // show search when list exceeds this count
  maxDropdownHeight="18rem"
  onBeforeSwitch={(fromGuid, toGuid) => true}
  onAfterSwitch={(group) => navigate('/dashboard')}
/>

When only one org exists, it renders as a static label that inherits the parent's text colour.

AdminRoute

Protects admin-only routes.

<Route path="/admin/*" element={
  <AdminRoute redirectTo="/dashboard">
    <AdminPage />
  </AdminRoute>
} />

NoAccessScreen

Standalone screen shown when access is denied. Includes the org switcher and a sign-out button.

Sign Out (1.6.0) calls signOut(): in one click it clears this package's caches and the local unified-auth session, ends the Totum ID session (unified-auth authService.logoutEverywhere() → the ID app's /api/logout) and lands the user on the sign-in page — no reload needed. Before 1.6.0 the button only called authService.logout(), which wipes the per-tab tokens but never navigates or reaches the Totum ID app, so nothing visibly happened until a refresh (and a surviving refresh-token cookie could sign the user straight back in). Pass onLogout (sync or async) to replace the default.

<NoAccessScreen
  title="Access Unavailable"
  message="Contact your organisation administrator."
  onLogout={async () => {
    await saveDraft();
    signOut();
  }}
/>

signOut() (1.6.0)

The same one-click sign-out, for the app's own menus and buttons:

import { signOut } from '@inovusmedical/org-switcher';

<button onClick={() => signOut()}>Sign out</button>
  1. clearOrgSwitcherState() — drops the package's per-user caches (Totum groups / roles, verify-org-role memo, token tracking).
  2. authService.logout(forceCookieRemoval?) — per-tab tokens and, on totum.surgery hosts, the current tenant cookie (newer unified-auth builds also broadcast the logout to the app's other tabs).
  3. cookie mode (deployed apps): authService.logoutEverywhere() — the Totum ID app ends the Cognito-backed session, clears the auth cookies on every Totum domain and shows the sign-in page. local mode (localhost): window.location.assign(localLoginPath ?? '/login'), like unified-auth's own local-mode logout.

Options: mode (override the detected authService.authMode), localLoginPath, forceCookieRemoval. Returns { mode, redirect } where redirect is 'id-app-logout', 'local-login', 'main-app-login' (a unified-auth build without logoutEverywhere() — only the tenant login page could be reached, with a console.warn) or 'none' (no window). Synchronous: the navigation has started when it returns.

useAuth().logout() from unified-auth clears the local session and goes to the tenant's /login, but does not end the Totum ID session either — prefer signOut() for a sign-out that sticks across Totum apps.

SwitchWarningModal

Confirmation modal for organisation switching.

<SwitchWarningModal
  isOpen={showWarning}
  onConfirm={handleConfirm}
  onCancel={handleCancel}
  title="Switch Organisation?"
  message="Any unsaved changes may be lost."
/>

Services

subscriptionService

const seats = await subscriptionService.getUserSeatAssignments(userGuid);
const groupSeats = await subscriptionService.getGroupSeatAssignments(groupGuid);
const productSeats = subscriptionService.getProductSeatAssignments(seats);              // configured productId list, exact
const bundled = subscriptionService.getProductSeatAssignments(seats, seatInfo.productInclusions); // + products that include them (1.4.0)
const ownOnly = subscriptionService.getOwnProductSeatAssignments(seats, seatInfo.productInclusions); // the app's own product(s) only
const adminSeats = subscriptionService.getAdminSeats(seats, seatInfo.productInclusions); // role_type "Admin" (case-insensitive) on the own product(s)
const otherSeats = subscriptionService.getNonAdminSeats(seats);     // every other role_type
const memberSeats = subscriptionService.getMemberSeats(seats);      // deprecated alias of getNonAdminSeats

totumGroupService

const groups = await totumGroupService.loadUserGroups(); // strict (1.5.0): throws TotumGroupsError on any failed call
const legacy = await totumGroupService.getUserGroups();  // fail-soft: [] on failure (cannot tell "failed" from "none")
const user = await totumGroupService.fetchCurrentUser();
totumGroupService.clearCache();

Every call carries a fresh token (getFreshAccessToken()), refreshed when expired or about to expire, and is retried once after a refresh on a 401. The strict load* methods (loadUserGroups, subscriptionService.loadGroupSeatAssignments / loadUserSeatAssignments) throw TotumGroupsError / SeatLookupError so a failed lookup is never mistaken for an empty answer; the older getters keep returning []. The same plumbing is exported for app code that calls Totum or subs itself: authFetch(url, init) / authFetchJson(url, init) (fresh bearer + one 401 retry) and getFreshAccessToken().

verifyOrgRoleService

const result = await verifyOrgRoleService.verify();   // never throws; cached per token until expires_at
// result: { ok, skipped, fromCache, status, systemAdmin, adminOrgs, expiresAt, error, raw,
//           systemManager, managerOrgs, claimsVersion,            // verified v2 token only (1.7.0)
//           seats, signedClaims, signedClaimsExpiresAt, claims, claimsStatus, claimsError }
isVerifiedAdminFor(result, groupGuid);                 // systemAdmin || adminOrgs includes groupGuid
isVerifiedManagerFor(result, groupGuid);               // systemManager || managerOrgs includes groupGuid (1.7.0)
verifyOrgRoleService.peek();                           // fresh cached result or null, no network
verifyOrgRoleService.isEnabled();
verifyOrgRoleService.clearCache();

Utilities

import {
  hasAdminRole,          // Cognito groups vs adminRoles
  isUserAdmin,
  isTotumAdminRole,      // "ClientAdmin" | "SystemAdmin" (case-insensitive)
  hasTotumAdminRole,
  hasGroupAdminRole,     // Totum roles vs adminRoles + built-in Totum admin roles
  isAdminSeatRoleType,   // role_type === "Admin" (case-insensitive)
  isAdminSeat,
  isValidSeat,           // any seat row
  decodeJwtPayload,
  extractUserSub,
  extractUserId,
  extractCognitoGroups,
  signOut,               // local session + Totum ID session, then the sign-in page (1.6.0)
  clearOrgSwitcherState, // drop the package's per-user caches
  detectAuthMode,        // 'cookie' | 'local' from unified-auth
  // Org claims verification — the same code as the /server entry, also usable in the browser
  verifyOrgClaims,
  hasSeat,
  isOrgAdmin,
  isOrgManager,          // verified v2 manager claims for an org (1.7.0)
  canManageOrg,          // isOrgAdmin || isOrgManager (1.7.0)
  isAppAdmin,
  isAdminSeatSubscriptionActive, // subscription rule behind isAppAdmin / hasAdminSeat
  isAdminGrantingSeat,
  ADMIN_SEAT_SUBSCRIPTION_STATUSES,
  OrgClaimsError,
  // Seats of other org members, read from totum-subs as the caller (1.3.0+)
  getOrgMemberSeats,
  membersHoldSeat,
  memberHoldsSeat,
  OrgMemberSeatsError,
  DEFAULT_SUBSCRIPTIONS_SUPABASE_URL,
  DEFAULT_SUBSCRIPTIONS_SUPABASE_ANON_KEY,
} from '@inovusmedical/org-switcher';

Development

npm install
npm run lint   # tsc for src and tests
npm test       # vitest (jsdom)
npm run build  # dist/

Environment Variables

Only needed for seat-gated apps. Simple org switcher apps require no env vars.

| Variable | Description | |----------|-------------| | VITE_PRODUCT_ID | Stripe product ID(s) for this app. Comma-separated for multiple. | | VITE_COGNITO_GROUP | Required Cognito group name(s). Comma-separated for multiple. |

More documentation

  • AGENT.md — standalone integration and reference for developers and AI agents (architecture, config, runtime, licensing, hooks, components, services, Totum/Supabase, database, build, troubleshooting). Distribute this file alone if you are not shipping the docs/ folder.
  • docs/ — optional maintainer-oriented split of topics for working in this repository (architecture, build, configuration, runtime, hooks, data layer, UI, database, extending, troubleshooting).

To design other Totum-internal npm packages (for example notifications or an event bus) and to keep keys and credentials handled safely, read docs/totum-ecosystem-packages.md.

License

UNLICENSED — Internal use only.