@unisim/sdk
v0.155.0
Published
Shared React SDK for the Universal Suite — auth, entitlements, usage telemetry, changelog, org admin.
Maintainers
Readme
@unisim/sdk
Shared React SDK for the Universal Suite — Ergo Assess UK, Cyber Assess UK, Workplace Assess, plus the central hub at app.unisim.co.uk. One package for auth, multi-tenant org data, branding, entitlements, changelog, and trial gating, so every product reads the same source of truth.
No product should import @supabase/supabase-js directly — go through the SDK so the underlying backend stays swappable.
Install
npm install @unisim/sdk @supabase/supabase-js reactReact 18+ is a peer dependency.
Quick start
Wrap your app in <UniversalProvider> once, then call hooks anywhere.
import { UniversalProvider, useUser, useOrgBranding } from '@unisim/sdk'
const config = {
supabaseUrl: process.env.NEXT_PUBLIC_PLATFORM_SUPABASE_URL!,
supabaseAnonKey: process.env.NEXT_PUBLIC_PLATFORM_SUPABASE_ANON_KEY!,
product: 'ergo_assess',
cookieDomain: process.env.NODE_ENV === 'production' ? '.unisim.co.uk' : undefined,
}
function App() {
return (
<UniversalProvider config={config}>
<Header />
{/* … */}
</UniversalProvider>
)
}
function Header() {
const { user } = useUser()
const branding = useOrgBranding()
return (
<header>
{branding.logo_url && <img src={branding.logo_url} alt="" />}
<span>{user?.email ?? 'Guest'}</span>
</header>
)
}What you get
Auth + session
useUniversal()— raw{ supabase, session, activeOrgId, … }useUser()—{ user, loading }useOrg()— active org + list of orgs the user belongs tosignInWithPassword(supabase, email, password)signOut(supabase)continueWithEmail(supabase, { email, password })— the unified form. Signs in, or creates the account if there isn't one, and reports which happened:signed-in·created·upgraded·unconfirmed·account-exists·trial-collision·error. There is no "does this email exist?" probe — the sign-in is attempted first, so an account's existence is only ever disclosed to someone who already submitted a password for it.abandonTrialAndSignInWithEmail(supabase, { email, password })— thetrial-collisionescape hatch: drop the anonymous session and sign in for real, leaving the trial work behind. Only after the user has agreed to that.<SignInDialog />renders all of the above; apps rarely need the functions. Two tabs, like Universal Family: Email code (type your email, get a 6-digit code; the same code signs in, creates the account, or upgrades a trial in place) and Apple / Google / Microsoft. An email typed on the first travels to the second as alogin_hint(Google and Microsoft pre-fill it; Apple has no such parameter). A password is a quiet second route on the code tab: it signs an existing account in and never creates one, and there is no password reset — the code is the way back in. The provider tab appears only for providers the Supabase project has switched on; without it there is no tab bar at all. In a native (Capacitor) shell the buttons use the system browser / Apple's own sheet instead of the web round trip — see Provider sign-in in the phone apps.sendSignInCode(supabase, { email })/verifySignInCode(supabase, { email, code, flow })— the code tab's logic, for other sign-in screens (the hub's). A trial session gets an email-change code, verified asemail_change, so the user id survives; an address that already has an account quietly becomes an ordinary code, so the form discloses nothing. Offline, the code is123456.startOAuthSignIn(supabase, provider, { redirectTo, loginHint })— Apple, Google (google) or Microsoft (azure). A trial session is upgraded in place withlinkIdentity, never replaced.fetchEnabledOAuthProviders(supabaseUrl, anonKey)— which providers are on, from/auth/v1/settings;[]on any doubt.startNativeOAuthSignIn(supabase, provider, { supabaseUrl, anonKey, loginHint })— the same thing inside a Capacitor app; resolvessigned-in/cancelled/errorwhen the round trip is over.nativeOAuthSupport()/nativeProvidersToOffer()decide whether to offer it.<OAuthButtons providers={…} />— the buttons. Withoutprovidersit shows Google and Microsoft only, because the hub's labels predate Apple.
Trial mode (anonymous-auth users)
useTrialMode()—{ isTrial, hasSession, email }for gating exports / multi-user<TrialBadge />— small "PRO" chip you append to gated buttons<UpgradeWall feature="export PDFs" />— full feature-replacement card
Suite-wide entities (all org-scoped, RLS-gated)
usePeople()+createPerson/deletePersonuseTeams()+useTeamMemberships()+createTeam/assignPersonToTeam/ …usePlaces()+createPlace/deletePlaceuseProjects()+createProject/updateProjectStatus/deleteProjectuseOrgBranding()— logo URL + brand colouruseOrgMembers()— org membership list with profile data
Subscriptions + entitlements
useSubscription()—{ tier, status, seat_count, current_period_end, credits }useCredits()— convenience wrapper for the metered balanceuseHasAccess(productCode)— feature/product entitlement checkuseHasFeature(productCode, feature)useSeat(productCode)useAccountLimits()— the whole entitlement picture in one object: tier, term, the purchased balance, the per-app free-token wall (app_free_tokens) and an admin to ask. A missing token row means available, not "no allowance" — rows are created lazily, so this hook is the one place that rule lives.<AccountLimits />— the panel that renders it. Mounted for you by<UserProfile />when its account header is clicked; passshowLimits={false}to the navbar's profile if a product shows entitlements its own way.- Delete my account (since 0.141.7) — a row under Sign out in that same
account panel, opening
<DeleteAccountDialog />: it warns that the Universal ID goes in every UNI·SIM product, needsdelete-alltyped, calls the platform'sdelete-accountfunction and signs out locally. App Review 5.1.1(v) and Google Play both require in-app deletion for an app whose sign-in creates accounts, so it is on by default inside a native (Capacitor) shell and off in a browser;showDeleteAccounton<UserProfile />/<UniversalAppsNavBar />overrides it either way. ⚠️ An app that draws its own delete row passesfalse(Universal PDF does, or a phone shows two).deleteMyAccount(),DeleteAccountDialoganddeleteAccountCopy()are exported for an app that wants them elsewhere.npm run test:delete-account(39 checks).
Org admin
useOrgMembers()/useOrgSeats()/useAuditLog()assignSeat/revokeSeat/reassignSeat
Suite navigation
<SuiteSwitcher current="ergo_assess" />— top-right product switcher with the canonical product list. Since 0.138.0 it also takes an optionalcurrentHref: with the menu open, a pointer click on the identity (mark + name) goes to that URL and closes the switcher instead of toggling.<UniversalAppsNavBar />passes it for you asproductHomeHrefor the app's catalogue href. Three gates are deliberate and must survive any refactor — only while the menu is open (on a phone there is no hover, so tap one must still open it), pointer clicks only (e.detail > 0, so Enter/Space still toggles), and navigating to the page you are already on is a close rather than alocation.assign(these are SPAs holding live state, and a Universal App's landing page is its root).<CompanyMenu />— "My Company" dropdown linking to the central hub — but only for a signed-in visitor. Since 0.131.0<UniversalNavBar />gives that slot to a "Pricing" link while nobody is signed in: every row in the dropdown needs an organisation, so to a visitor it was five links to a login wall. Override with the navbar'spricingHref, or passnullto keep the old behaviour (on-prem has no subscription page to point at). ⚠️ Since 0.142.7 a native (Capacitor) shell carries neither purchase link. A store app may not send people outside the store to buy a subscription (App Store 3.1.1, Play's payments policy), so there the navbar's defaultpricingHrefisnull, a signed-out visitor sees no Pricing and no My Company, and<CompanyMenu />drops its Billing row (showBilling, default off in a native shell, on in a browser). Both overrides are honoured. A browser is unchanged.npm run test:native-purchase-links(21 checks).- "Sign in" opens the in-app
<SignInDialog />on both bars. The Apps bar has done this since 0.141.x;<UniversalNavBar />only got it in 0.142.9, and until then its Sign in was a plain link to the hub. ⚠️ In a Capacitor app that link LEAVES THE APP — the web view navigates to app.unisim.co.uk and there is no way back to a signed-in app. Reported from Workplace Assess on Android. The row stays an<a href>tohubLoginHref, so middle- and ctrl-click still open the hub in a tab, and anoAccountsproduct gets no dialog. An anonymous trial is upgraded in place, not replaced (emailCode.ts).npm run test:navbar-sign-in(21 checks). - …and in a native shell that row carries no
hrefat all (0.142.10). There is no new tab on a phone, so the attribute buys nothing there — and it is a hazard, because a host app may legitimately intercept an off-origina[href]in a document-level capture listener, which runs before React sees the click. Universal PDF'sexternalLinks.tsis exactly that (it keeps an outward link from backgrounding the app and breaking everyposition: fixeddialog on resume): itpreventDefaulted first, the row's own guard stood down ondefaultPrevented, and Sign in opened app.unisim.co.uk/login in a Custom Tab. A bare/loginhonours no?return=and forwards to the Assess portal, so the tap ended on a website with the app still signed out. Reported on Android, 2026-09-16. With nohref,closest('a[href]')misses the row and the click is the dialog's. The web keeps its link, untouched. <UniversalBar />— the 4 px gradient brand strip across every product header<UKFlag />— region indicator for UK products- The first click on a hover-opened menu leaves it open (since 0.141.9).
Every dropdown here —
<UserProfile />'s pill,<KnowledgeBaseMenu />,<ChangelogMenu />,<CompanyMenu />,<SuiteSwitcher />— opens on hover as well as click, and the click used to be a plain toggle: the pointer arriving opened the menu and the click itself shut it, so the first click on "Actions" looked like it did nothing. An open from hover or focus is now provisional and the next click (or Enter) confirms it; the click after that closes. Touch is unchanged (no hover-open there at all). All five shareuseHoverMenu()inhover.ts— a new dropdown should use it rather than wiringonMouseEnter+ a toggle by hand.npm run test:hover-click(77 checks).
Changelog
useChangelog()— fetches the suite-wide changelog feed (defaults tohttps://changelog.unisim.co.uk/changelog.json)
Usage telemetry
<UsageTracker />(since 0.140.0) — mount it once inside<UniversalProvider>. It starts the batcher and sends exactly onesession.openedrow per page life for a signed-in visitor with an org, which is what fills god-mode's "last product used". Signed-out visitors send nothing. Import it; do not copy it — it replaced a 20-lineUsageTracker.tsxthat had been pasted into nineteen apps.- It takes no
productprop: the row's product isconfig.product, already typedProductCode, and a second place naming it could only disagree with the first. What the compiler cannot check is the Postgresproduct_codeenum — if the code is missing there, every signed-in insert fails with22P02, and the batcher's one error now names the code so you know which migration to write (Docs_UNI_SIM/new-universal-app.md§1). useUsageTracker()/track(name, props)— the parts, for a product that needs its own events. ⚠️ Track that the app was opened, never what was in it: several Universal Apps promise we never see the file, and an event carrying a byte count breaks that promise.npm run test:usage-tracker— one row signed in (plain and StrictMode), none signed out, a negative control with no tracker mounted, and the22P02message.
Tune this app, and Global Tuning (since 0.143.0, renamed 0.148.0)
⚠️ The per-app row is called "Tune this app" since 0.148.0, and the app's own
settings page (settingsHref) is the last LINK inside that dialog rather than a
second "App settings" row in the menu (James, 2026-09-17: "Can we combine app
settings with app preferences?"). A host still passes settingsHref exactly as
before — the SDK moved where it renders, not what the host provides — and
showAppPreferences={false} no longer hides the row when there is a
settingsHref, or the settings page would have nowhere to live.
James, 2026-09-17: per-app "Settings" is now App preferences, owned by the SDK, and the things nobody wants to set once per app — Language and Colour scheme — are Global preferences that each app can override.
The menu.
<UserProfile>(and so both navbars) has a Global Tuning row where the language row used to be — in the account panel for a signed-in user, in the list for a guest — and an App preferences row directly under the host'sactions, for everyone. Each opens<PreferencesDialog>; the menu closes first.Global Tuning: Language (a
<select>of native names,LANGUAGE_LABELS) and Colour scheme (Light — the default — / Dark / System, a radio group). When the current app overrides either, the dialog says so ("Universal PDF uses its own language: Français").App preferences: Language, first option "Follow global: English (US)" naming the current global value; Colour scheme the same way when the app passes its theme store, or "{app} is always light/dark" when it does not; then the app's own rows. Choosing "Follow global" removes the override rather than storing today's global value.
New props on
<UniversalAppsNavBar>,<UniversalNavBar>and<UserProfile>:appPreferences?: ReactNode— the app's own rows, wrapped inAppMenuProvider, souseCloseAppMenu()closes the dialog. Style them fortheme, as withactions.themeStore?: ThemeStore— pass yourcreateThemeStore(...)to offer a colour scheme override.fixedColorScheme?: 'light' | 'dark'— the one look of an app with no store. Defaults totheme.appName?: string— defaults to the catalogue name forproduct.showAppPreferences?: boolean— default true.
⚠️
showLanguageSelector={false}no longer removes a row. The Global Tuning row also holds the colour scheme, so it stays;falseleaves the Language sections out of both dialogs.<PreferencesDialog kind="app" | "global" open onClose theme themeStore fixedColorScheme appName showLanguage>{rows}</PreferencesDialog>is exported for a host that opens one from elsewhere. It must render inside<UniversalProvider>.Storage (all strings exact):
| What | Where | Absent means | |---|---|---| | Global language |
universal:language—.unisim.co.ukcookie on Assess, localStorage on a Universal App (unchanged) | browser locale, elseen| | Global colour scheme |universal:color-scheme— same store as the language, and always mirrored to localStorage |light| | An app's language override | localStorageuniversal:language:<config.product>| follow global | | An app's colour scheme override | localStorage under the app's own theme-store key (unisim-<app>-theme) | follow global |Signed in, the global pair follows the account: the
suiterow ofuser_app_prefs(migration 0151) holds{ language, colorScheme }. On sign-in the row wins and is applied without being written back; with no row, one is seeded only from values this device explicitly stored (never defaults or the browser locale); every global change upserts the pair. Fire-and-forget and silent on failure; skipped in mock mode. App overrides are never synced. ⚠️ This deliberately lets a signed-in person's language cross between the Universal Apps and Assess — the separation below exists for anonymous visitors on a shared machine, which a row keyed on the user's id cannot affect.npm run test:preferences(51 checks) andnpm run test:profile-menu-order(53).
One app: no Global Tuning (since 0.144.0)
James, 2026-09-17: "start with app and show global once they start using a second app, or if they are part of an enterprise? It's kinda pointless if they only use one app".
- Until the split, the menu has no Global Tuning row, and App preferences shows Language and Colour scheme (radios) without "Follow global". They show what the app is using (a leftover override included) and a change sets the global value and clears any override. So when the split does appear, the choice already made is the global one and every app follows it — nothing to migrate, nothing that looks different.
- The split (
useUniversal().splitPreferences,useGlobalPreferences().split) turns on, and stays on (universal:preferences-split=1, same store as the global language), when any of these is true:- this device has opened two apps:
universal:apps-seen, a JSON array of product codes in the global prefs' store (the.unisim.co.ukcookie across Assess, localStorage per origin on a Universal App); - the account has:
appsin thesuiterow ofuser_app_prefs. Each signed-in app adds itself (not the device's list, which may be someone else's on a shared machine), andappsis carried on every global-prefs upsert; - the account has any
org_membersrow (onelimit(1)read on sign-in; skipped in mock mode).
- this device has opened two apps:
central(the hub and the Assess portal) never counts as an app.- ⚠️ Blind spots, accepted: a signed-out person on two Universal Apps on different origins, or on two native apps, stays in the one-app view. Their apps cannot see each other, so there is no global value that could carry between them anyway.
<PreferencesDialog kind="app" combined>is the one-app dialog. An app drawing its own appearance control (Jukebox) should checkuseGlobalPreferences().splitand, when false, offer plain Light / Dark / System bound tosetColorSchemewith the override cleared.
"There are X total users (Y live)" (since 0.145.0)
Every app shows how many people use it, and how many are using it right now, as the last line of the navbar's profile menu. Nothing to wire up: the provider reports the app as in use (guests included) and the menu reads the figure.
- Counting (migration 0175): the provider calls
app_presence_beat(product, install_id)on load, on sign-in/out and every 45 s while the page is visible. The install id is a random UUID in localStorage (unisim:install-id). An account is one person across all its devices and platforms; a guest install is one person unless somebody signed in on it. "Live" = a beat in the last 2 min. - Reading:
app_user_counts(product)→{ total, live }, only while the menu is open. The line hides until a real number arrives. - It opens on the WHOLE SUITE since 0.150.0 —
suite_user_counts(), "There are a total of 1,234 users across all UNI·SIM apps (12 live)" (migration 0177) — and a tap switches to this app's own figure and back. An account is one person suite-wide; a guest is one person per browser origin, so the suite total is a utilisation figure, not a headcount. ⚠️ The tap is NOT remembered (since 0.152.0). It used to be, inunisim:user-count-scope, and one tap months earlier is what made every app on a browser open on its own figure with the suite hidden behind a click. The key is now deleted on mount;COUNT_SCOPE_KEYis exported only so it can be cleared. [Correction 2026-09-19: this bullet still described the remembered choice.] - Press and hold for the breakdown (since 0.154.0, migration 0184):
suite_user_counts_by_product()→ one row per app, in a popup over the menu. Right-click and Shift+Enter open it too, so the gesture is not pointer-only. ⚠️ The rows do not add up to the suite total and are not meant to — an account using four apps is four rows there and one person in the suite figure. The popup says so; never sum them to produce a total. App names come from the switcher catalogue (productDisplayName), and a product code this build has never heard of is listed under a prettified name rather than dropped, because the database enum gains values without the SDK being rebuilt. A database without 0184 shows "not available" and nothing else breaks. Turn it off with<UserCountLine breakdown={false} />. - Off switches:
presence: falseinUniversalConfig;showUserCount={false}on<UserProfile>. The hubs (product: 'central') never beat or show it. - Elsewhere:
<UserCountLine />/useUserCounts()for a home screen, and<UserCountBreakdown />/useUserCountsByProduct()for the per-app list. Apps without<UniversalProvider>usestartPresence(supabase, product),fetchUserCounts,fetchUserCountsByProductandformatUserCounts(language, counts)directly.
Languages
SUPPORTED_LANGUAGES:en,en-gb,fr,es,it,de,pt-BR,pt-PT,tr. The language pickers in Global Tuning and App preferences offer both Portuguese options, "Português (Brasil)" and "Português (Portugal)".Three values since 0.143.0.
useLanguage()returns{ language, setLanguage, globalLanguage, appLanguage, setGlobalLanguage, setAppLanguage }.languageis what the app shows (appLanguage ?? globalLanguage), so everything that already reads it keeps working. ⚠️setLanguagemeanssetGlobalLanguage— what it always did.setAppLanguage(null)removes the override.useGlobalPreferences()returns the global values only:{ language, setLanguage, colorScheme, setColorScheme }.⚠️ There has been no
ptsince 0.141.6. The oldptwas European Portuguese, so it is nowpt-PT.resolveLanguage('pt')returnspt-PT, so a storedptpreference keeps showing what it showed before. A browser locale ofpt-BRresolves topt-BR; every otherpt-*(pt-PT,pt-AO, …) resolves topt-PT.setLanguage()normalises its argument the same way. The shared cookie is also safe with apps on older SDKs: they readpt-BR/pt-PTaspt.Your app's own dictionaries. The SDK only translates its own chrome; each app's strings are its own. Look them up with
pickTranslation(dicts, language), or walklanguageFallbacks(language)yourself. That way an app with only aptdictionary keeps working under both codes:pt-BR→pt-PT→pt→enpt-PT→pt→pt-BR→en
The "exact code, else base language, else English" lookup that Cyber Assess and unisim-central already use maps both codes to
ptas well. ⚠️ A map typedRecord<Language, …>with the SDK'sLanguagenow needspt-BRandpt-PTkeys, notpt.<PrivacyNote>is translated, and so is the About dialog's privacy section: the headline, the claim, the caveat, the link and the tooltips. Apps keep passing English: everysubject/except/headlinestring an app used before 0.141.6 is catalogued by its exact English text insrc/privacyCopy.ts. A string that is not catalogued renders the whole note in English, never half-translated. Alternatively pass a map (LocalizedText), e.g.except={{ en: 'backup', fr: 'la sauvegarde' }}. ⚠️ Read the rules at the top ofprivacyCopy.tsbefore changing any wording. This is the suite's legally load-bearing sentence, and no translation may promise more than the English does.npm run test:i18n(plain Node) andnpm run test:privacy-note(in a browser).
Brand colour on a dark ground
useOrgBranding().brand_color_accessibleis legible on white only. For a dark theme, callbrandColorOn(branding.brand_color, 'dark')(since 0.141.6). It measures againstDARK_THEME.surface(#0a0e16), or pass your own surface's hex instead of'dark'. Same hue; only the lightness moves.- Use the default 4.5:1 for text. Use
{ minRatio: 3 }for a fill whose label is picked bytextOn(). The SDK already does this in dark theme for the navbar's company tile and for the company switcher's tiles.
Chips
Two styles, one rule (owner decision 2026-09-14; BRANDING.md, Components): Orbit for anything you click or promote, Value for anything that carries a value.
import { Chip, ChipToggle, ValueChip } from '@unisim/sdk'
<Chip icon={<Lock />}>Nothing uploaded</Chip>
<ChipToggle selected={on} onClick={() => setOn(!on)}>Lighting</ChipToggle>
<ValueChip label="HSE">DSE Regs 1992</ValueChip>
<ValueChip label="REBA 6" tone="warn">Medium risk</ValueChip>- The components inject their own CSS: no import, no Tailwind
@source. On plain markup, use theu-chip/u-vchipclasses and callinstallChipStyles()once. A page with no build step pastesCHIP_CSS. - Light by default.
ground="dark", ordata-u-ground="dark"on any ancestor, switches a chip to the dark ground. So does adarkclass (whatcreateThemeStoresets on<html>) ordata-theme="dark"on an ancestor (since 0.142.1), which means an app's dark mode needs no wiring. A chip's ownground="light"beats a dark ancestor. --u-chip-accent/--u-chip-accent-text(and their-darktwins) re-tint the arc and the selected label for a tenant. Passbrand_color_accessible, neverbrand_color.size="sm"for tight spots: table cells, card corners.- ⚠️ The chip CSS is unlayered and injected after the app's own CSS, so in a Tailwind v4
app it beats utilities on the properties it sets.
px-4,text-smorhidden sm:inline-flexon a chip do nothing. Put layout and visibility classes on a wrapper. Margins (since 0.142.2) and thehiddenattribute (since 0.142.4) do work.chipStyles.tsexplains why a cascade layer isn't the fix. npm run test:chipsmeasures every label's contrast on both grounds and checks the sweep, the grounds and the geometry. A negative control proves the ring check can fail.
Theme (light / dark / system)
createThemeStore(storageKey)(since 0.140.0) — an app's theme preference, as a hook. Call it once at module level:export const useThemeStore = createThemeStore('unisim-<app>-theme'), thenuseThemeStore((s) => s.pref),s.effective('light' | 'dark', with'system'resolved),s.setPref(p), anduseThemeStore.getState()outside React — the same shape the zustand copies had, without zustand.It toggles the
darkclass on<html>(Tailwind'sdark:variant) and setscolor-scheme, at import time, so a dark-mode user never sees a light frame.⚠️ Opens LIGHT and stays light until the user chooses otherwise — the suite rule, and deliberately stronger than "respect the OS".
'system'follows the OS live, but only once chosen.⚠️ The key is the user's saved choice. Changing it on a shipped app silently resets everyone to light. Existing apps keep the key their old copy used.
Since 0.143.0 that key is the app's OVERRIDE of a global scheme. Absent, the store follows
localStorage['universal:color-scheme'](exported asGLOBAL_COLOR_SCHEME_KEY, defaultlight), which Global Tuning sets. The state gainsoverride: ThemePref | null,global: ThemePrefandsetOverride(pref | null)(nullremoves the key);prefisoverride ?? global, andsetPref(p)issetOverride(p). The store repaints on the provider'suniversal:color-schemewindow event (GLOBAL_COLOR_SCHEME_EVENT,CustomEventwith the pref asdetail) and onstorageevents for either key. Existing choices are not migrated: a storeddarknow reads as "this app overrides with Dark", which is what the user chose. Pass the store to the navbar asthemeStoreto offer the override in App preferences.⚠️ Update your pre-paint script in
index.html, or someone whose only choice is the global one gets a light first frame and then a flip:<script> try { var p = localStorage.getItem('unisim-<app>-theme') || localStorage.getItem('universal:color-scheme') || 'light' var d = p === 'dark' || (p === 'system' && matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.toggle('dark', d) document.documentElement.style.colorScheme = d ? 'dark' : 'light' } catch (e) {} </script>npm run test:theme-store(34 checks).
Collapsibles that show what is inside them
Opening a fold low on the page used to leave its contents below the bottom of the screen:
the row you clicked stayed put and you had to scroll to find out whether anything had
happened. Every product inside <UniversalProvider> now scrolls an opening fold into
view automatically — no per-app wiring, and nothing to remember in a product written
later. It moves by the smallest amount that brings the contents on screen, never scrolls
the row you clicked off the top (so contents taller than the window read from the top
down), and does nothing at all when the fold already fits.
- Works out of the box for
<details>/<summary>, and for a state-driven collapsible whose trigger carriesaria-expandedandaria-controls="<panel id>". A trigger with noaria-controlsis deliberately left alone — nothing on the page says which box is its panel. Adding it is a one-line a11y fix that opts the fold in. revealOnExpand={false}on the provider config turns it off; pass{ topOffset: 72 }(etc.) to nudge the geometry. A pinned header's height is measured, not configured, so a sticky navbar needs nothing.installRevealOnExpand()/revealExpanded(panel, header?)are exported for an app that never mounts the provider, or a collapsible the document listeners cannot see.npm run test:reveal-on-expand— geometric browser checks, including a negative control that proves the harness catches the original bug.
QR codes
<UnisimQr value={url} size={176} label="the mobile signing link" />— the house-style code: ink modules, orange finder eyes, the UNI·SIM mark in the centre, error correction pinned atH. Click (or Enter) enlarges it full-screen on a dimmed backdrop — that's on by default, since the reason a code is on screen is that someone wants to point a phone at it. Passenlargeable={false}for a plain image.<QrLightbox value={url} onClose={…} />— the enlarged view on its own, for a code drawn by something else.title,hintandactionsoverride the caption and hang buttons (Copy PNG, Download) below the plate;<UnisimQr lightbox={{ … }}>passes them through.unisimQrPngDataUrl(value, size)/unisimQrPngBlob(value, size)— the same code as a PNG, for an<img>, a clipboard write, a download, or a stamp drawn into a generated PDF.
The colours are measured, not chosen: brand orange modules are 2.34:1 on white, below
the ~3:1 a decoder's binariser needs, and the light-on-dark version is an inverted code that
strict readers refuse. The orange goes on the finder eyes instead. Don't restyle without
re-running a decode check against zxing (Universal_Beam/e2e/beam.e2e.ts does).
qr-code-styling is a normal dependency but is imported dynamically, so it stays out of the
bundle — and out of any server render — of an app that never draws a code.
Where the session is stored
UniversalProvider picks the store for the platform it is running on — you do
not configure this beyond passing cookieDomain:
| Platform | Store | Carries across |
| --- | --- | --- |
| Browser on *.unisim.co.uk | cookie scoped to the parent zone | every suite subdomain |
| Browser on localhost / Electron | localStorage | nothing (origin-scoped) |
| Browser on a product's own domain (ergoassess.app) | localStorage | into the zone on a click — see below |
| Native (Capacitor), plugin present | shared suite store (see below) | every suite app on the device |
| Native, plugin missing | localStorage | nothing, but it does persist |
⚠️ A Capacitor app cannot use the cookie. It runs at capacitor://localhost,
where a cookie carrying Domain=.unisim.co.uk is rejected outright by the
domain-match rule — the write silently does nothing and the read returns null.
Every product computes cookieDomain from import.meta.env.PROD, which is true
in the native bundle too, so before 0.123.0 the native builds could not
persist a session at all: sign in, force-quit, signed out again, nothing
logged. npm run test:session-storage pins that behaviour.
A product served from its own domain as well (Ergo at ergoassess.app,
beside assess.unisim.co.uk/ergo) passes the same cookieDomain on both. From
0.155.0 the SDK checks the page really sits under the zone before using the
cookie; before, every write on ergoassess.app was refused, so no session
survived a reload and each page load made a new anonymous user. There the
session is app-local, and links into the zone carry it across (handoff.ts):
a click on a *.unisim.co.uk link by a signed-in, non-anonymous visitor asks the
session-handoff edge function for a single-use magic-link code, goes to the
Assess portal with it in the fragment, and the portal redeems it into the
.unisim.co.uk cookie before going on to the link. It is a NEW session, never a
copy of the refresh token (two stores spending one token get the whole family
revoked), and a real sign-in already in the zone is never replaced. Only
redeemers on 0.155.0+ understand the fragment, which is why there is one: the
portal. npm run test:handoff.
⚠️ A shared store does not by itself give you a shared SIGN-OUT. supabase-js
announces a session it finds on resume, and announces nothing when it finds the
store emptied — so an app already running keeps showing a signed-in UI after you
sign out somewhere else. UniversalProvider closes that by re-reading the store
on every resume; npm run test:shared-signout pins both legs.
The shared store is two mechanisms behind one name
UnisimSuiteAuth presents the same three calls on both platforms, but what is
underneath could hardly be less alike, and the difference decides what can go
wrong:
| | iOS | Android |
| --- | --- | --- |
| Mechanism | one Keychain access group | a ring of ContentProviders, one per app |
| Shared by | the OS, for apps of the same team | the apps themselves, gated on a signature permission |
| Written on sign-in | once | to this app, then pushed to every installed peer |
| Fails to share when | the entitlement is missing | the peer list is empty, or signatures differ |
Android has no shared box to put anything in — sharedUserId was deprecated in
API 29 and is unusable for anything new — so there is nowhere central to write.
Each app therefore hosts SuiteAuthProvider behind
uk.co.unisim.suite.permission.SUITE_AUTH (protection level signature, so only
same-key apps get through), and reads and writes fan out across the installed
peers. Sources: android/src/main/java/uk/co/unisim/sdk/.
⚠️ On Android the peer list is the thing that breaks. API 30+ hides packages
you have not declared an interest in, so the <queries> block in the module's
AndroidManifest.xml is load-bearing: without an app listed there it is
invisible, keeps a private session, and nothing reports a problem. Its
<provider> authorities are the single canonical list of participating suite
apps — the Java side keeps no second copy, it filters what the package manager
admits to by the .unisimsuiteauth suffix. Adding an app to the suite means
adding one line there. (The <package> list below it is a different list for a
different job — see the switcher section.)
⚠️ Same key, in practice, means the same BUILD TYPE. Debug builds are signed
with ~/.android/debug.keystore and release builds with the upload key. Install
a mix on one phone and the platform treats them as two different vendors: access
is refused, every app keeps its own session, and a refused peer is a normal
enough thing to meet that nothing shouts. Test all-debug or all-release.
Turning on the shared store for a native app
The SDK ships both native halves itself (ios/Sources/UnisimSuiteAuthPlugin and
android/), so npx cap sync picks them up with no Xcode or Gradle surgery.
Android needs nothing further — the permission, the <queries> list and the
provider all arrive through manifest merging.
iOS has one thing the SDK cannot do for you, the entitlement:
node ../universal-platform/scripts/add-suite-keychain.mjs <path-to-app-repo>That writes ios/App/App/App.entitlements declaring
$(AppIdentifierPrefix)co.uk.unisim.suite and points CODE_SIGN_ENTITLEMENTS
at it in both configurations. Then npx cap sync ios and rebuild.
⚠️ An app with no entitlements file cannot use the Keychain at all — every
call returns errSecMissingEntitlement (-34018), not just the shared ones. An
unsigned simulator build (CODE_SIGNING_ALLOWED=NO) gets the same, which is
worth knowing before you go hunting for a bug in the plugin.
hasSharedSuiteStore() (and chooseSessionStorage().kind) report which store an
app actually got — a build that quietly landed on native-local still signs in,
it just does not carry to the other apps, and nothing about the behaviour makes
that visible.
Provider sign-in in the phone apps (Apple / Google / Microsoft)
<SignInDialog /> offers the Apple / Google / Microsoft tab inside a Capacitor
app too (since 0.141.8). The web round trip cannot work there — it returns to
capacitor://localhost, which is on no redirect allowlist; Google refuses
sign-in inside an embedded web view (403 disallowed_useragent); and
navigating the app's only web view away unloads the app — so a native shell
does it the way native apps must:
| | iOS | Android |
| --- | --- | --- |
| Apple | the OS's Sign in with Apple sheet → Supabase id_token grant | the system browser, like the others |
| Google / Microsoft | ASWebAuthenticationSession → Supabase /authorize | a Custom Tab → Supabase /authorize |
| Comes back to | the session's own completion handler | OAuthCallbackActivity, merged in from the SDK's manifest |
| Callback address | <bundle id>://auth-callback | <applicationId>://auth-callback |
| Per-app config | the com.apple.developer.applesignin entitlement | none |
Source: src/nativeOAuth.ts, ios/Sources/UnisimSuiteAuthPlugin/SuiteOAuth.swift,
android/src/main/java/uk/co/unisim/sdk/OAuthCallbackActivity.java. The three
native methods (oauthSupport, oauthStart, appleSignIn) live on the
existing UnisimSuiteAuth plugin, for the same reason the app-opening methods
do: a new plugin class is absent from every app until each one re-syncs.
What it guarantees, each pinned by npm run test:native-oauth:
- The session is suite-wide. It is installed with
supabase.auth.setSession, which writes through the shared suite store above — one sign-in on the phone signs in every suite app, exactly like the email code. - A trial session is linked, never replaced — the browser leg goes through
/user/identities/authorizewith the trial's bearer token, the Apple leg through theid_tokengrant withlink_identity, and a failed link is returned, never retried as a sign-in.identity_already_existsandmanual_linking_disabledarrive with the web flow's codes, so the dialog's collision panel works unchanged. (The native "leave my trial behind" path does not sign out first: the new session replaces the trial only when it lands, so cancelling keeps the trial.) - PKCE, and only a code is accepted back. S256 where WebCrypto exists,
plainotherwise (supabase-js's own fallback). Tokens in a fragment, or a callback on any address but this app's own, are refused — on Android any app can fire a deep link at us, and a code is worthless without the verifier. - Nothing appears until it can work. The tab needs the provider switched on in Supabase and the plugin's native half (an app synced against an older SDK shows no tab). On iPhone it also needs Apple switched on: App Review 4.8 will not take Google or Microsoft in an iPhone app without Sign in with Apple beside them, so with Apple off the app offers the email code alone.
Turning it on for an app
- Depend on
@unisim/sdk≥ 0.141.8 andnpm run cap:sync. Android needs nothing more. - iOS:
node ../universal-platform/scripts/add-sign-in-with-apple.mjs <path-to-app-repo>(afteradd-suite-keychain.mjs, which creates the entitlements file), then rebuild. The App ID needs the Sign in with Apple capability as well. - The project — console settings, recorded nowhere in code:
- every
<bundle id>://auth-callbackon the Supabase redirect allowlist; - every iOS bundle id in the Apple provider's Client IDs (beside the web Services ID) — the sheet's token is issued to the bundle id;
- Allow manual linking on (trials link);
- the providers themselves switched on.
- every
⚠️ Every one of those fails far from its cause:
| Missing | Looks like | | --- | --- | | the callback on the allowlist | GoTrue silently sends the browser to the Site URL; the sheet sits on the website and never comes back | | the bundle id in Apple's Client IDs | the Apple sheet succeeds, then Supabase refuses the token's audience | | the entitlement | the Apple sheet fails at once with ASAuthorizationError 1000 — the same code as a device with no Apple ID | | the App ID capability | a device build fails to provision; a simulator build is fine, so it proves nothing | | manual linking | every trial visitor gets "can't link" | | a re-sync against this SDK | no provider tab at all |
Known limit: on Android, if the system kills the app while the browser is up, the callback arrives with nobody holding the PKCE verifier; the app comes back to the front and the person signs in again.
Opening a suite app instead of its website
On a phone, a switcher row for a product you have installed opens the app,
and says so with an Installed badge. In a browser nothing changes at all.
⚠️ An https link cannot do this, and adding associated domains would not
help. A WKWebView does not honour universal links for navigation that happens
inside it, so a Capacitor app tapping https://opensource.unisim.co.uk/pdf
loads the website in the webview it is already in, however the domains are
configured. Handing off needs the platform's app-to-app channel: a custom URL
scheme on iOS, a launch intent on Android.
SUITE_NATIVE_APPS in src/suiteApps.ts is the source list — id, scheme, iOS
bundle id, Android package. A product missing from it (Ergo Assess, Exports,
anything web-only) keeps today's link, which is the right answer for a product
with no app.
| | iOS | Android |
| --- | --- | --- |
| Matches on | the app's custom URL scheme | the app's package |
| Asked with | canOpenURL / UIApplication.open | getLaunchIntentForPackage |
| Needs, per app | CFBundleURLTypes and every other app's scheme in LSApplicationQueriesSchemes | nothing — the SDK's manifest merges in |
| Wrong list looks like | "not installed" | "not installed" |
⚠️ Both platforms fail the same silent way, and it is the reason this is
tested. An undeclared scheme or package is not an error: it is the ordinary
answer for an app you do not have. A drifted list does not break the switcher,
it just quietly stops offering one product on one platform. npm run
test:suite-apps pins the registry against the Android manifest and against the
switcher's own catalogue, and pins the browser case to today's behaviour.
The iOS half is per app and generated, never hand-edited:
node ../universal-platform/scripts/add-suite-app-links.mjs <path-to-app-repo>Add --check to fail instead of write. Then npx cap sync ios and rebuild —
Info.plist is compiled into the bundle, so running the script is not shipping
the change.
⚠️ Adding a new native app means three edits, not one: the registry, the
<package> list in android/src/main/AndroidManifest.xml, and a run of the
script above in every app repo (they all need the new scheme in their queries
list, not just the new app).
⚠️ An app that gains a native build later is the same three edits, and nothing
will tell you. Ergo Assess shipped a SwiftUI/Compose pair while the registry
still listed it as web-only; its switcher row went on loading the website inside
the caller's webview, which is indistinguishable from an app you have not
installed. test:suite-apps now names Ergo specifically, because a category
("web-only products") cannot be tested and a named product can.
Not every suite app is a Capacitor app. The script tries ios/App/App/ first,
then falls back to the single Info.plist under ios/ — which is how it finds
Ergo's ios/ErgoAssessCapture/Info.plist — and reads the bundle id from
ios/project.yml rather than the Capacitor config in that case. It refuses to
choose when a repo has more than one iOS target.
The row stays a real <a href> throughout — the handoff is a click handler over
the top, so a launch that fails still navigates and long-press/copy-link keep
working. openSuiteApp() resolving false is the fallback signal, not an
error. A comingSoon product never launches even when installed, because its
row does not navigate either.
…and where to GET one you have not got
The same row, the other answer. In a native shell a product you do not have
installed shows an App Store ↗ / Google Play ↗ chip and its row points at
the store, once — and only once — that listing is actually live:
| Registry field | Set it when |
| --- | --- |
| appStoreId | Apple's numeric id, the day the listing goes on sale. Not the bundle id, and not the day you submit |
| playListed | true the day the Play listing is public. There is no id to record — the URL is just androidPackage |
Ergo Assess was the first to get one (appStoreId 6811535068, 2026-09-15);
every other field is absent today, and absent means the row keeps its website
link. That is the whole safety property: this ships inert and lights up one
product at a time as each store approves it, with no second place to edit.
⚠️ The two URLs per platform are not interchangeable. An
https://apps.apple.com/… link tapped inside a Capacitor webview loads the App
Store's web page in that webview — the same "website inside the app you were
already in" this whole feature exists to end. itms-apps: and market: are not
http(s), so the webview hands them to the OS instead of navigating:
| | in the shell | in a browser |
| --- | --- | --- |
| iOS | itms-apps://itunes.apple.com/app/id<ID> | https://apps.apple.com/app/id<ID> |
| Android | market://details?id=<pkg> | https://play.google.com/store/apps/details?id=<pkg> |
Getting those two rows the wrong way round reintroduces the original bug wearing
a badge that says it fixed it, so test:suite-apps pins all four by
construction and test:suite-store drives the iOS and Android ones in a real
Chromium against a stubbed bridge.
⚠️ The switcher offers this in the native shell only, and that is deliberate
rather than unfinished. On the mobile web the https link is the product
working correctly — someone on opensource.unisim.co.uk tapping Universal QR
wants the QR tool, not a store page — so retargeting there would hide a working
web app behind a download. suiteAppStore() handles the browser case and is
exported for a product that wants to offer it behind its own affordance; the
switcher just does not make that call on everyone's behalf.
An installed app is never offered a download (installed wins), and neither is
a comingSoon one — whatever the badge says is what the row does.
Multi-tenant model
This SDK is the client side of a Supabase-backed multi-tenant schema (see universal-platform/supabase/migrations). Every read/write is scoped to the user's active org via the is_org_member() helper in RLS policies. Anonymous-auth users get the same hooks; trial caps (3 people / 1 team / 2 places / 1 project) are enforced server-side by the enforce_anonymous_trial_caps() trigger.
Publishing
cd packages/sdk
./publish.sh patch # or minor / majorThis runs npm version, builds, publishes, commits the version bump, and pushes — see publish.sh for the exact sequence. prepublishOnly runs typecheck && build as a safety net, and the dist/ folder is the only thing shipped (per files).
License
MIT © Universal Simulation Ltd
