Clearly.HUB SDK
Connect your web app to Clearly.HUB with a script, some config and one server handler. You get "Sign in with Clearly.HUB", the hosted screens (choose a plan, pick data, save a result, choose a hub) and each hub's subscription to your app. The refresh token stays on your server; the page gets the access token only when it asks, for protected data.
Preview (0.x). The API may still change between minor versions. Proprietary: see LICENSE.
Quick start
1. Register your app in Clearly.HUB (on your app's page, no code):
| Setting | Value |
|---|---|
| Go to app | https://app.example.com/clearly-hub/sign-in |
| Sign-in client (H2M): callback URLs | https://app.example.com/clearly-hub/callback, https://app.example.com/clearly-hub/answer, https://hub.clearly.app/components |
| Sign-in client: sign-out URLs | https://app.example.com, https://hub.clearly.app/components |
| M2M client | create it, keep the secret on your server |
| Plans | give each your own plan id as its External reference, for example pro |
2. Mount the server handler (Node 24+):
import express from 'express';
import { clearlyHub } from '@clearly-hub/node';
const hub = clearlyHub({
clientId: process.env.CLEARLY_HUB_CLIENT_ID,
m2mClientId: process.env.CLEARLY_HUB_M2M_CLIENT_ID,
m2mClientSecret: process.env.CLEARLY_HUB_M2M_CLIENT_SECRET,
appId: process.env.CLEARLY_HUB_APP_ID, // your app's id in Clearly.HUB
appUrl: 'https://app.example.com',
secret: process.env.SESSION_SECRET, // 32+ random characters
});
const app = express();
app.use('/clearly-hub', hub.express());
Not Express? hub.handler takes a Request and returns a Response, for Next.js route handlers,
Hono, Bun or Deno. Mount it at /clearly-hub/*.
3. Add the script and a button:
<!-- @0.1: follows 0.1.x fixes, not a breaking 0.2 -->
<script src="https://cdn.jsdelivr.net/npm/@clearly-hub/[email protected]/dist/clearly-hub.js"></script>
<clearly-hub-button action="sign-in"></clearly-hub-button>
<clearly-hub-button action="SELECT_DATA"></clearly-hub-button>
<script type="module">
const session = await clearlyHub.session(); // { signedIn, user }
window.addEventListener('clearly-hub-answer', (event) =>
console.log(event.detail),
);
</script>
Or npm install @clearly-hub/browser and import { clearlyHub } from '@clearly-hub/browser'. With React and TypeScript, add import '@clearly-hub/browser/react' once, so JSX knows <clearly-hub-button>.
The browser package
clearlyHub.session() | { signedIn: false }, or the signed-in user (sub, email, name, appUserId) |
clearlyHub.signIn(), signOut() | Go through Clearly.HUB and come back to this page |
clearlyHub.signIn({ display: 'popup' }) | Sign in in a popup; this page stays. Resolves with the session. Also links a Clearly.HUB account to someone signed in with your own login |
clearlyHub.open(action, { data, display }) | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
clearlyHub.result() | After a redirect screen: its answer, on the page it came back to |
clearlyHub.getToken() | The person's key for protected and paid data, or null when signed out. See Protected data |
clearlyHub.onAnswer(callback) | Every answer, once each, from a popup or after a redirect; also when you start listening after it arrived (React). Returns a function that stops listening |
<clearly-hub-button action display data label> | Branded button for sign-in, sign-out and each screen |
clearly-hub-answer event | Every answer, from a popup or after a redirect |
clearly-hub-sign-in event | A sign-in popup is done; detail is the session |
Styling the button. It uses your page's font. Attributes for the common cases:
variant="outline", size="small" or size="large". CSS custom properties for your look:
--clearly-hub-color, --clearly-hub-text-color, --clearly-hub-radius, --clearly-hub-padding,
--clearly-hub-font. For anything else, style the real button through ::part(button):
clearly-hub-button {
--clearly-hub-color: #0b7471;
--clearly-hub-radius: 999px;
}
clearly-hub-button::part(button) {
text-transform: uppercase;
}
Screens open in a popup by default (data-display="redirect" or "iframe" on the script tag
changes that). A blocked popup falls back to a redirect.
In a dialog on your page (display: 'iframe'): the screen shows in a centred dialog and uses
the person signed in to your app, so it doesn't sign in itself, the way the Google Drive picker
works. A popup does the same. Nobody signed in with Clearly.HUB here: your app's sign-in comes
first (for a popup, in that same window). Needs a Clearly.HUB
release that allows this (preview). If your app has its own CSP: frame-src https://hub.clearly.app.
Style the dialog with --clearly-hub-frame-width, --clearly-hub-frame-height,
--clearly-hub-backdrop, or clearly-hub-frame::part(dialog | iframe | close).
| Action | Send in data | answer when result is SUCCESS |
|---|---|---|
SELECT_SUBSCRIPTION | subscription (_id, externalReference = your plan id), subscribedHub (_id, name), hasMultipleSubscriptions | |
SELECT_DATA | optionally { formats: ['geojson', 'wms'] } to offer only those | activeHub (_id, name), resources (datasetId, datasetTitle, resourceId, resourceName, url, format, accessibility, requiresToken, hasAccess) |
REGISTER_RESOURCE | { url, format } of your result, at a public https URL | activeHub (hub id), dataset (datasetId, datasetTitle), resource (resourceId, resourceName) |
SELECT_HUB | hub (_id, name) | |
SELECT_DIGITAL_TWIN | activeHub (_id, name), digitalTwin (_id, title, description, previewImage, ownerHub, findability, viewerUrl, resources as in SELECT_DATA plus selected and subLayers (name, selected)) |
Every answer also has user (_id, email): who answered, set by Clearly.HUB. Its _id is the
person's sub, the same as session().user.sub.
Protected data
A protected or paid resource from SELECT_DATA (requiresToken: true) has a url on Clearly.HUB,
which checks the person's key on every request. Send it as the Authorization header. In the page:
const response = await fetch(resource.url, {
headers: { Authorization: await clearlyHub.getToken() },
});
Map layers (WMS, WMTS, tiles) load many URLs themselves: use a Leaflet, OpenLayers or Cesium option
or plugin that adds headers to each request. The key lasts an hour; getToken() gets a new one in
time, so call it again rather than keeping the value. On your server, hub.fetchAsUser(req, url)
does the same.
The key is the person's own Clearly.HUB key: send it only to resource URLs from SELECT_DATA (Clearly.HUB's
data proxy), and keep scripts you don't trust off pages that use it. fetchAsUser refuses other URLs.
The Node package
hub.express(), hub.handler | The routes: /sign-in, /callback, /sign-out, /session, /open, /answer, /result, /token |
onSignIn(person, { request, req }) | Called after each sign-in. Return your own user id to keep it in the session, or { appUserId, redirectTo } to also choose the next page |
onAnswer(result, { user, data, request, req }) | Called with each checked answer before it reaches the browser, for example to set up a workspace. data is what the screen was sent; what you return reaches the page as result.app |
hub.getUser(req) | Who's signed in, in your own routes |
hub.fetchAsUser(req, url) | Fetch protected or paid data (from SELECT_DATA) on your server, with the person's key. Only URLs on Clearly.HUB's data proxy |
hub.getSubscription(hubId), getSubscriptions() | A hub's subscription to your app, or all of them |
hub.getUserContext(req) | The subscribed hubs the signed-in person is in, with their role (ADMIN or MEMBER) and plan; null when nobody is signed in |
hub.reportUsage({ request or sub, hubId, quantity }) | Report billable use; Clearly.HUB bills the hub. Pass sub for a Clearly.HUB account you stored, without a live sign-in |
hub.datasets, hub.resources, hub.authMethods | Register data in a hub, as the signed-in person or as your app; see Registering data |
hub.getProxyContext(req, { audience }) | In an API behind Clearly.HUB's data proxy: who calls (sub, subType), for which hub, with which role, and the subscription. null without the proxy's header; see Behind the data proxy |
hub.getUserToken(req), getAppToken() | The person's key and your app's key, for calls of your own |
Behind the data proxy
When your API is a protected resource, Clearly.HUB's data proxy can tell it who calls: it adds an
x-oup-context header, a JWT signed with Clearly.HUB's key. getProxyContext checks the signature,
the expiry and, with audience (your resource id), that it was made for this resource:
app.get('/api/flood-depth', async (req, res) => {
const context = await hub.getProxyContext(req, { audience: resourceId });
if (!context) return res.sendStatus(401);
if (context.hub.role !== 'ADMIN') return res.sendStatus(403);
// context.sub, context.subscription?.id, context.activeHub, ...
});
It throws a ClearlyHubError with code invalid-context when the header is there but doesn't
verify.
The header only proves who calls if the request really came through the proxy. Check the
proxy's own secret too, the resource's authentication method (for example a CUSTOM_HEADER), or
accept traffic from the proxy only. The proxy drops an x-oup-context a client sends itself.
For now the proxy adds the header only when the resource's authentication method has
forwardSubscriptionId on.
Your own session in the hooks. With hub.express(), the hooks get Express's req as
context.req, so they can use your own session (for example express-session) alongside Clearly.HUB's:
const hub = clearlyHub<express.Request>({
// ...
onSignIn: (person, { req }) => {
const user = findOrCreateUser(person);
req!.session.userId = user.id;
return { appUserId: user.id };
},
});
What it checks for you: every screen answer's Ed25519 signature, that it answers a screen
opened in this browser, once (state), and that it was given to your app and that screen
(client_id, callback URL, action).
What to trust in an answer: it's a snapshot of the moment the person clicked. For access later,
ask getSubscription(hubId) when it's used. Who's signed in is the session's user.
The session is encrypted in cookies, so there's nothing to store; it's split over several cookies because tokens are large. A copied cookie stays valid until its refresh token expires: signing out clears only this browser's copy.
Local Clearly.HUB: pass urls: { screens, api, proxy, auth, issuer }; see
examples/express/.env.example.
Another path than /clearly-hub, for example /auth/hub: set it in all three places.
- Server:
clearlyHub({ basePath: '/auth/hub', ... })and mount it there,app.use('/auth/hub', hub.express()). The handler only answers underbasePathand builds its callback URLs from it, so mounting alone isn't enough. - Page:
data-base-path="/auth/hub"on the script tag, orclearlyHub.configure({ basePath: '/auth/hub' }). - Clearly.HUB: the sign-in client's callback URLs become
{app}/auth/hub/callbackand{app}/auth/hub/answer, and Go to app{app}/auth/hub/sign-in.
Registering data
Register your app's results in a hub from your own UI, without the REGISTER_RESOURCE screen:
datasets, their resources, and the authentication methods protected resources use. Server only.
Every call starts with who acts: a request, for the person signed in on it, or 'app', for your
app's own key (m2mClientId/m2mClientSecret), for example after a long job when nobody is
signed in. A request without anyone signed in fails with UNAUTHENTICATED.
const { datasetId } = await hub.datasets.create(req, {
title: 'Flood depth',
ownerHubId,
findability: 'LIMITED',
});
// Protected data: an authentication method first, then a resource behind the data proxy
const method = await hub.authMethods.create(req, ownerHubId, {
name: 'Our tile server',
type: 'CUSTOM_HEADER',
authData: { parameterName: 'X-Api-Key', parameterValue: apiKey },
});
const resource = await hub.resources.add(req, datasetId, {
name: 'Flood depth (WMS)',
format: 'wms',
sourceUrl: 'https://tiles.example.com/wms',
authenticationMethodId: method.authMethodId,
});
| Call | Returns |
|---|---|
hub.datasets.create(actor, input) | { datasetId } |
hub.datasets.update(actor, datasetId, input) | { datasetId } |
hub.datasets.delete(actor, datasetId) | nothing |
hub.datasets.get(actor, datasetId, { activeHubId? }) | the dataset, with resources |
hub.resources.add(actor, datasetId, input) | the resource |
hub.resources.update(actor, datasetId, resourceId, input) | the resource |
hub.resources.delete(actor, datasetId, resourceId) | nothing |
hub.authMethods.list(actor, hubId, { type?, activeHubId? }) | the hub's methods |
hub.authMethods.create(actor, hubId, input) | the method |
hub.authMethods.update(actor, hubId, authMethodId, input) | the method |
hub.authMethods.delete(actor, hubId, authMethodId) | nothing |
Who may. Clearly.HUB decides. A person needs to be ADMIN of the hub (for datasets and resources: the dataset's owner hub). Your app's key has its own roles: admin of the hub that owns your app and its sub-hubs, member of that hub's parents, and editor in hubs that subscribed to your app (and their sub-hubs).
| Call | Person | Your app: owner hub and sub-hubs | Its parents | Hubs that subscribed |
|---|---|---|---|---|
| datasets: create, update, delete | ADMIN | yes | no | yes |
| datasets: get, public | anyone | yes | yes | yes |
datasets: get, LIMITED | in a hub that may find it | yes | yes | if one may find it |
| resources: add, update, delete | ADMIN | yes | no | yes |
| authMethods: create, update, delete | ADMIN | yes | no | no |
| authMethods: list | ADMIN | yes | no | no |
Moving a dataset to another ownerHubId also needs the person or app to be in that hub. Market
masters can't make changes.
Good to know
datasets.updateandresources.updatesend everything, as create does: what you leave out may be cleared (spatialis). Start fromdatasets.get(), which returns the shapeupdatetakes.spatialis stored on update only, not on create.- A protected resource has an
authenticationMethodIdand asourceUrl(the address behind the proxy), and nourl: Clearly.HUB makes it, on its data proxy. An open resource has aurlonly. Paid data also has asubscriptionModelId, a plan of the owner hub.formatis one of Clearly.HUB's formats (wms,wfs,wmts,tilejson,api, ...). URLs need a domain:localhostis refused, uselvh.me. authDataholds secrets. Clearly.HUB never gives it back, andupdatereplaces all of it. Fields pertype:BASIC_AUTHusername,password;URL_PARAMETERandCUSTOM_HEADERparameterName,parameterValue;ISHARE_AUTH_METHODtokenEndpoint,serviceProviderEORI,entitledPartyEORI;JWT_ENDPOINTtokenEndpoint,username,password,usernameField,passwordField(andaccessTokenProperty,refreshTokenProperty);S3s3AccessKeyId,s3SecretAccessKey;OAUTH2_CLIENT_CREDENTIALStokenEndpoint,clientId,clientSecret(andaccessTokenProperty,secretAsHeader,scope). The types (AuthDataByType) say the same.- Deleting a dataset deletes its resources and cancels subscriptions to its paid ones.
- Errors are
ClearlyHubErrors with Clearly.HUB's message andcode(BAD_USER_INPUT,FORBIDDEN,UNAUTHENTICATED). The SDK checks what it can before sending, with the same code.
Not covered (yet)
- Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
onAnswerforSELECT_SUBSCRIPTION. - Other server languages (.NET, Python, ...): the browser package works with any backend that answers the same routes. Contact us at [email protected] and we'll help you with yours.
Working on this repo
npm install
npm run build # both packages
npm test # vitest
npm run typecheck
npm run lint
npm run example # examples/express on http://localhost:5175 (copy .env.example to .env first)