@clearly-hub/node 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,13 +11,13 @@ hub) and each hub's subscription to your app. Tokens stay on your server.
11
11
 
12
12
  **1. Register your app in Clearly.HUB** (on your app's page, no code):
13
13
 
14
- | Setting | Value |
15
- | --- | --- |
16
- | Go to app | `https://app.example.com/clearly-hub/sign-in` |
14
+ | Setting | Value |
15
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
16
+ | Go to app | `https://app.example.com/clearly-hub/sign-in` |
17
17
  | 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` |
18
- | Sign-in client: sign-out URLs | `https://app.example.com`, `https://hub.clearly.app/components` |
19
- | M2M client | create it, keep the secret on your server |
20
- | Plans | give each your own plan id as its *External reference*, for example `pro` |
18
+ | Sign-in client: sign-out URLs | `https://app.example.com`, `https://hub.clearly.app/components` |
19
+ | M2M client | create it, keep the secret on your server |
20
+ | Plans | give each your own plan id as its _External reference_, for example `pro` |
21
21
 
22
22
  **2. Mount the server handler** (Node 20+):
23
23
 
@@ -43,7 +43,8 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
43
43
  **3. Add the script and a button:**
44
44
 
45
45
  ```html
46
- <script src="https://cdn.jsdelivr.net/npm/@clearly-hub/browser/dist/clearly-hub.js"></script>
46
+ <!-- @0.1: follows 0.1.x fixes, not a breaking 0.2 -->
47
+ <script src="https://cdn.jsdelivr.net/npm/@clearly-hub/browser@0.1/dist/clearly-hub.js"></script>
47
48
 
48
49
  <clearly-hub-button action="sign-in"></clearly-hub-button>
49
50
  <clearly-hub-button action="DATA_PICKER"></clearly-hub-button>
@@ -54,18 +55,18 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
54
55
  </script>
55
56
  ```
56
57
 
57
- Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-hub/browser'`.
58
+ 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>`.
58
59
 
59
60
  ## The browser package
60
61
 
61
- | | |
62
- | --- | --- |
63
- | `clearlyHub.session()` | `{ signedIn: false }`, or the signed-in user (`sub`, `email`, `name`, `appUserId`) |
64
- | `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
65
- | `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
66
- | `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
67
- | `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
68
- | `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
62
+ | | |
63
+ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
64
+ | `clearlyHub.session()` | `{ signedIn: false }`, or the signed-in user (`sub`, `email`, `name`, `appUserId`) |
65
+ | `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
66
+ | `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
67
+ | `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
68
+ | `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
69
+ | `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
69
70
 
70
71
  **Styling the button.** It uses your page's font. Attributes for the common cases:
71
72
  `variant="outline"`, `size="small"` or `size="large"`. CSS custom properties for your look:
@@ -73,32 +74,51 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
73
74
  `--clearly-hub-font`. For anything else, style the real button through `::part(button)`:
74
75
 
75
76
  ```css
76
- clearly-hub-button { --clearly-hub-color: #0b7471; --clearly-hub-radius: 999px; }
77
- clearly-hub-button::part(button) { text-transform: uppercase; }
77
+ clearly-hub-button {
78
+ --clearly-hub-color: #0b7471;
79
+ --clearly-hub-radius: 999px;
80
+ }
81
+ clearly-hub-button::part(button) {
82
+ text-transform: uppercase;
83
+ }
78
84
  ```
79
85
 
80
86
  Screens open in a popup by default (`data-display="redirect"` on the script tag changes that). A
81
87
  blocked popup falls back to a redirect.
82
88
 
83
- | Action | Send in `data` | `answer` when `result` is `SUCCESS` |
84
- | --- | --- | --- |
85
- | `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
86
- | `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
87
- | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
88
- | `SELECT_HUB` | | `hub` (`_id`, `name`) |
89
+ | Action | Send in `data` | `answer` when `result` is `SUCCESS` |
90
+ | --------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
91
+ | `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
92
+ | `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
93
+ | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
94
+ | `SELECT_HUB` | | `hub` (`_id`, `name`) |
89
95
 
90
96
  ## The Node package
91
97
 
92
- | | |
93
- | --- | --- |
94
- | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
95
- | `onSignIn(person)` | Called after each sign-in; return your own user id to keep it in the session |
96
- | `onAnswer(result, { user })` | Called with each checked answer before it reaches the browser, for example to set up a workspace |
97
- | `hub.getUser(req)` | Who's signed in, in your own routes |
98
- | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
99
- | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
100
- | `hub.reportUsage({ request, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub |
101
- | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
98
+ | | |
99
+ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
101
+ | `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 |
102
+ | `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` |
103
+ | `hub.getUser(req)` | Who's signed in, in your own routes |
104
+ | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
105
+ | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
106
+ | `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 |
107
+ | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
108
+
109
+ **Your own session in the hooks.** With `hub.express()`, the hooks get Express's `req` as
110
+ `context.req`, so they can use your own session (for example express-session) alongside Clearly.HUB's:
111
+
112
+ ```ts
113
+ const hub = clearlyHub<express.Request>({
114
+ // ...
115
+ onSignIn: (person, { req }) => {
116
+ const user = findOrCreateUser(person);
117
+ req!.session.userId = user.id;
118
+ return { appUserId: user.id };
119
+ },
120
+ });
121
+ ```
102
122
 
103
123
  **What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
104
124
  opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
package/dist/index.cjs CHANGED
@@ -44,6 +44,23 @@ var SCREEN_ACTIONS = [
44
44
  function isScreenAction(value) {
45
45
  return SCREEN_ACTIONS.includes(value);
46
46
  }
47
+ function checkScreenData(action, data) {
48
+ if (action !== "REGISTER_RESOURCE") return void 0;
49
+ const { url, format } = data ?? {};
50
+ if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
51
+ return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
52
+ }
53
+ let hostname;
54
+ try {
55
+ hostname = new URL(url).hostname;
56
+ } catch {
57
+ return `REGISTER_RESOURCE: "${url}" is not a URL`;
58
+ }
59
+ if (!hostname.includes(".")) {
60
+ return "REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost";
61
+ }
62
+ return void 0;
63
+ }
47
64
 
48
65
  // ../shared/src/protocol.ts
49
66
  var DEFAULT_BASE_PATH = "/clearly-hub";
@@ -91,7 +108,8 @@ function resolveConfig(options) {
91
108
  signedOutUrl: options.signedOutUrl ?? appUrl,
92
109
  onSignIn: options.onSignIn,
93
110
  onAnswer: options.onAnswer,
94
- fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
111
+ // Looked up on every call, so a fetch wrapped later (logging, tracing) is used
112
+ fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
95
113
  isSecure: appUrl.startsWith("https://")
96
114
  };
97
115
  }
@@ -106,7 +124,7 @@ function toNodeMiddleware(handle, appUrl) {
106
124
  else if (value !== void 0) headers.set(name, value);
107
125
  }
108
126
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
109
- const response = await handle(new Request(url, { method: req.method, headers }));
127
+ const response = await handle(new Request(url, { method: req.method, headers }), req);
110
128
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
111
129
  next();
112
130
  return;
@@ -206,18 +224,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
206
224
  var MAX_PENDING_SCREENS = 10;
207
225
  function createHandler(parts) {
208
226
  const { config, sessions } = parts;
209
- return async function handle(request) {
227
+ return async function handle(request, req) {
210
228
  const url = new URL(request.url);
211
229
  if (!url.pathname.startsWith(config.basePath)) return notFound();
212
230
  const route = url.pathname.slice(config.basePath.length) || "/";
213
231
  if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
232
+ const host = request.headers.get("host");
233
+ const appHost = new URL(config.appUrl).host;
234
+ if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
235
+ return redirect(`${config.appUrl}${url.pathname}${url.search}`);
236
+ }
214
237
  const { data, chunkCount } = await sessions.read(request);
215
238
  const before = JSON.stringify(data);
216
239
  let response;
217
240
  try {
218
- response = await routeRequest(route, url, data, parts);
241
+ response = await routeRequest(route, url, data, parts, { request, req });
219
242
  } catch (error) {
220
- response = errorPage(error instanceof AnswerError ? 400 : 500, error.message);
243
+ const isRefusedAnswer = error instanceof AnswerError;
244
+ if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
245
+ response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
221
246
  }
222
247
  if (JSON.stringify(data) !== before) {
223
248
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -227,12 +252,12 @@ function createHandler(parts) {
227
252
  return response;
228
253
  };
229
254
  }
230
- async function routeRequest(route, url, session, parts) {
255
+ async function routeRequest(route, url, session, parts, incoming) {
231
256
  switch (route) {
232
257
  case ROUTES.signIn:
233
258
  return startSignIn(url, session, parts);
234
259
  case ROUTES.callback:
235
- return finishSignIn(url, session, parts);
260
+ return finishSignIn(url, session, parts, incoming);
236
261
  case ROUTES.signOut:
237
262
  for (const key of Object.keys(session)) delete session[key];
238
263
  return redirect(parts.signIn.signOutUrl());
@@ -241,7 +266,7 @@ async function routeRequest(route, url, session, parts) {
241
266
  case ROUTES.open:
242
267
  return openScreen(url, session, parts);
243
268
  case ROUTES.answer:
244
- return receiveAnswer(url, session, parts);
269
+ return receiveAnswer(url, session, parts, incoming);
245
270
  case ROUTES.result: {
246
271
  const result = session.result ?? null;
247
272
  delete session.result;
@@ -256,16 +281,17 @@ async function startSignIn(url, session, { config, signIn }) {
256
281
  session.signIn = pending;
257
282
  return redirect(authorizationUrl);
258
283
  }
259
- async function finishSignIn(url, session, { config, signIn }) {
284
+ async function finishSignIn(url, session, { config, signIn }, incoming) {
260
285
  const pending = session.signIn;
261
286
  if (!pending) return redirect(config.appUrl);
262
287
  delete session.signIn;
263
288
  const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
264
289
  const { person, tokens } = await signIn.finish(currentUrl, pending);
265
- const appUserId = await config.onSignIn?.(person) ?? void 0;
290
+ const decision = await config.onSignIn?.(person, incoming);
291
+ const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
266
292
  session.user = { ...person, appUserId };
267
293
  session.tokens = tokens;
268
- return redirect(`${config.appUrl}${pending.returnTo}`);
294
+ return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
269
295
  }
270
296
  async function readSession(session, parts) {
271
297
  const accessToken = await getFreshAccessToken(session, parts);
@@ -291,6 +317,14 @@ function openScreen(url, session, { config, screens }) {
291
317
  if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
292
318
  const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
293
319
  const dataParam = url.searchParams.get("data");
320
+ let data;
321
+ try {
322
+ data = dataParam ? JSON.parse(dataParam) : void 0;
323
+ } catch {
324
+ return errorPage(400, "The screen data is not valid JSON");
325
+ }
326
+ const dataProblem = checkScreenData(action, data);
327
+ if (dataProblem) return errorPage(400, dataProblem);
294
328
  const state = crypto.randomUUID();
295
329
  session.screens = pruneScreens(session.screens);
296
330
  session.screens[state] = {
@@ -298,18 +332,12 @@ function openScreen(url, session, { config, screens }) {
298
332
  display,
299
333
  ref: url.searchParams.get("ref") ?? state,
300
334
  returnTo: safeReturnTo(url, config),
301
- createdAt: Date.now()
335
+ createdAt: Date.now(),
336
+ data
302
337
  };
303
- return redirect(
304
- screens.screenUrl(
305
- action,
306
- state,
307
- session.user?.email,
308
- dataParam ? JSON.parse(dataParam) : void 0
309
- )
310
- );
338
+ return redirect(screens.screenUrl(action, state, session.user?.email, data));
311
339
  }
312
- async function receiveAnswer(url, session, { config, screens }) {
340
+ async function receiveAnswer(url, session, { config, screens }, incoming) {
313
341
  const query = readRawQuery(url);
314
342
  const { state, response } = await screens.readAnswer(
315
343
  query.get("data") ?? "",
@@ -322,7 +350,12 @@ async function receiveAnswer(url, session, { config, screens }) {
322
350
  delete session.screens[state];
323
351
  let result = toScreenResult(pending.action, response);
324
352
  try {
325
- await config.onAnswer?.(result, { user: session.user });
353
+ const app = await config.onAnswer?.(result, {
354
+ ...incoming,
355
+ user: session.user,
356
+ data: pending.data
357
+ });
358
+ if (app !== void 0) result = { ...result, app };
326
359
  } catch (error) {
327
360
  result = { action: pending.action, result: "FAILURE", error: error.message };
328
361
  }
@@ -348,12 +381,14 @@ function pruneScreens(screens = {}) {
348
381
  return Object.fromEntries(fresh);
349
382
  }
350
383
  function safeReturnTo(url, config) {
351
- const returnTo = url.searchParams.get("returnTo") ?? "/";
352
- if (!returnTo.startsWith("/") || returnTo.startsWith("//")) return "/";
384
+ return safePath(url.searchParams.get("returnTo") ?? void 0, config) ?? "/";
385
+ }
386
+ function safePath(path, config) {
387
+ if (!path || !path.startsWith("/") || path.startsWith("//")) return void 0;
353
388
  try {
354
- return new URL(returnTo, config.appUrl).origin === new URL(config.appUrl).origin ? returnTo : "/";
389
+ return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
355
390
  } catch {
356
- return "/";
391
+ return void 0;
357
392
  }
358
393
  }
359
394
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
@@ -362,6 +397,13 @@ var json = (body) => new Response(JSON.stringify(body), {
362
397
  });
363
398
  var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
364
399
  var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
400
+ function describeError(error) {
401
+ const messages = [];
402
+ for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
403
+ if (!messages.includes(current.message)) messages.push(current.message);
404
+ }
405
+ return messages.join(": ") || String(error);
406
+ }
365
407
  function errorPage(status, message) {
366
408
  const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
367
409
  return html(
@@ -379,6 +421,15 @@ var HubApi = class {
379
421
  config;
380
422
  appToken;
381
423
  subscriptions;
424
+ // A call to Clearly.HUB. When it can't be reached at all, say where: "fetch
425
+ // failed" alone doesn't tell you which service isn't running
426
+ async call(url, init) {
427
+ try {
428
+ return await this.config.fetch(url, init);
429
+ } catch (error) {
430
+ throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
431
+ }
432
+ }
382
433
  get hasAppKey() {
383
434
  return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
384
435
  }
@@ -391,7 +442,7 @@ var HubApi = class {
391
442
  throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
392
443
  }
393
444
  const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
394
- const response = await this.config.fetch(`${this.config.urls.auth}/oauth2/token`, {
445
+ const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
395
446
  method: "POST",
396
447
  headers: {
397
448
  "Content-Type": "application/x-www-form-urlencoded",
@@ -414,7 +465,7 @@ var HubApi = class {
414
465
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
415
466
  return this.subscriptions.value;
416
467
  }
417
- const response = await this.config.fetch(`${this.config.urls.api}/api/app-api/subscriptions`, {
468
+ const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
418
469
  headers: { Authorization: `Bearer ${await this.getAppToken()}` }
419
470
  });
420
471
  if (!response.ok) {
@@ -434,7 +485,7 @@ var HubApi = class {
434
485
  }
435
486
  // Report one billable use; Clearly.HUB bills the hub
436
487
  async reportUsage(usage) {
437
- const response = await this.config.fetch(this.config.urls.usage, {
488
+ const response = await this.call(this.config.urls.usage, {
438
489
  method: "POST",
439
490
  headers: {
440
491
  "Content-Type": "application/json",
@@ -641,20 +692,25 @@ function clearlyHub(options) {
641
692
  async getSubscription(hubId) {
642
693
  return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
643
694
  },
644
- // Report billable use by the signed-in person in a hub
695
+ // Report billable use in a hub, by the person signed in with Clearly.HUB
696
+ // (request), or by a Clearly.HUB account you stored earlier (sub), for
697
+ // example for someone who signed in with your own login, or a nightly job
645
698
  async reportUsage({
646
699
  request,
700
+ sub,
647
701
  hubId,
648
702
  quantity = 1
649
703
  }) {
650
- const { data } = await parts.sessions.read(request);
651
- if (!data.user) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
704
+ const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
705
+ if (!person) {
706
+ throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
707
+ }
652
708
  const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
653
709
  if (subscription?.status !== "ACTIVE") {
654
710
  throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
655
711
  }
656
712
  await parts.api.reportUsage({
657
- sub: data.user.sub,
713
+ sub: person,
658
714
  hubId,
659
715
  subscriptionId: subscription.subscriptionId,
660
716
  quantity
package/dist/index.d.cts CHANGED
@@ -10,6 +10,7 @@ interface ScreenData {
10
10
  format: string;
11
11
  };
12
12
  }
13
+ declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
13
14
  interface HubRef {
14
15
  _id: string;
15
16
  name: string;
@@ -61,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
61
62
  action: A;
62
63
  result: 'SUCCESS';
63
64
  answer: ScreenAnswers[A];
65
+ app?: unknown;
64
66
  } | {
65
67
  action: A;
66
68
  result: 'CANCELLED' | 'FAILURE';
67
69
  answer?: undefined;
68
70
  error?: string;
71
+ app?: unknown;
69
72
  };
70
73
 
71
74
  interface SessionUser {
@@ -111,7 +114,19 @@ interface SignedInPerson {
111
114
  email: string;
112
115
  name?: string;
113
116
  }
114
- interface ClearlyHubOptions {
117
+ interface HookContext<Req = unknown> {
118
+ request: Request;
119
+ req?: Req;
120
+ }
121
+ interface SignInDecision {
122
+ appUserId?: string;
123
+ redirectTo?: string;
124
+ }
125
+ interface AnswerContext<Req = unknown> extends HookContext<Req> {
126
+ user?: SessionUser;
127
+ data?: unknown;
128
+ }
129
+ interface ClearlyHubOptions<Req = unknown> {
115
130
  clientId: string;
116
131
  m2mClientId?: string;
117
132
  m2mClientSecret?: string;
@@ -121,10 +136,8 @@ interface ClearlyHubOptions {
121
136
  hubUrl?: string;
122
137
  urls?: Partial<ClearlyHubUrls>;
123
138
  signedOutUrl?: string;
124
- onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
125
- onAnswer?: (result: ScreenResult, context: {
126
- user?: SessionUser;
127
- }) => Promise<void> | void;
139
+ onSignIn?: (person: SignedInPerson, context: HookContext<Req>) => Promise<string | SignInDecision | void> | string | SignInDecision | void;
140
+ onAnswer?: (result: ScreenResult, context: AnswerContext<Req>) => unknown;
128
141
  fetch?: typeof fetch;
129
142
  }
130
143
  interface ClearlyHubUrls {
@@ -145,8 +158,8 @@ type CookieSource = Request | {
145
158
  headers: Record<string, string | string[] | undefined>;
146
159
  };
147
160
 
148
- declare function clearlyHub(options: ClearlyHubOptions): {
149
- handler: (request: Request) => Promise<Response>;
161
+ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
162
+ handler: (request: Request, req?: unknown) => Promise<Response>;
150
163
  express: () => (req: http.IncomingMessage & {
151
164
  originalUrl?: string;
152
165
  }, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
@@ -156,13 +169,14 @@ declare function clearlyHub(options: ClearlyHubOptions): {
156
169
  getAppToken: () => Promise<string>;
157
170
  getSubscriptions: () => Promise<AppSubscription[]>;
158
171
  getSubscription(hubId: string): Promise<AppSubscription | null>;
159
- reportUsage({ request, hubId, quantity, }: {
160
- request: CookieSource;
172
+ reportUsage({ request, sub, hubId, quantity, }: {
173
+ request?: CookieSource;
174
+ sub?: string;
161
175
  hubId: string;
162
176
  quantity?: number;
163
177
  }): Promise<void>;
164
178
  };
165
179
  type ClearlyHub = ReturnType<typeof clearlyHub>;
166
180
 
167
- export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, clearlyHub, isScreenAction };
168
- export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
181
+ export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
182
+ export type { AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ interface ScreenData {
10
10
  format: string;
11
11
  };
12
12
  }
13
+ declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
13
14
  interface HubRef {
14
15
  _id: string;
15
16
  name: string;
@@ -61,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
61
62
  action: A;
62
63
  result: 'SUCCESS';
63
64
  answer: ScreenAnswers[A];
65
+ app?: unknown;
64
66
  } | {
65
67
  action: A;
66
68
  result: 'CANCELLED' | 'FAILURE';
67
69
  answer?: undefined;
68
70
  error?: string;
71
+ app?: unknown;
69
72
  };
70
73
 
71
74
  interface SessionUser {
@@ -111,7 +114,19 @@ interface SignedInPerson {
111
114
  email: string;
112
115
  name?: string;
113
116
  }
114
- interface ClearlyHubOptions {
117
+ interface HookContext<Req = unknown> {
118
+ request: Request;
119
+ req?: Req;
120
+ }
121
+ interface SignInDecision {
122
+ appUserId?: string;
123
+ redirectTo?: string;
124
+ }
125
+ interface AnswerContext<Req = unknown> extends HookContext<Req> {
126
+ user?: SessionUser;
127
+ data?: unknown;
128
+ }
129
+ interface ClearlyHubOptions<Req = unknown> {
115
130
  clientId: string;
116
131
  m2mClientId?: string;
117
132
  m2mClientSecret?: string;
@@ -121,10 +136,8 @@ interface ClearlyHubOptions {
121
136
  hubUrl?: string;
122
137
  urls?: Partial<ClearlyHubUrls>;
123
138
  signedOutUrl?: string;
124
- onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
125
- onAnswer?: (result: ScreenResult, context: {
126
- user?: SessionUser;
127
- }) => Promise<void> | void;
139
+ onSignIn?: (person: SignedInPerson, context: HookContext<Req>) => Promise<string | SignInDecision | void> | string | SignInDecision | void;
140
+ onAnswer?: (result: ScreenResult, context: AnswerContext<Req>) => unknown;
128
141
  fetch?: typeof fetch;
129
142
  }
130
143
  interface ClearlyHubUrls {
@@ -145,8 +158,8 @@ type CookieSource = Request | {
145
158
  headers: Record<string, string | string[] | undefined>;
146
159
  };
147
160
 
148
- declare function clearlyHub(options: ClearlyHubOptions): {
149
- handler: (request: Request) => Promise<Response>;
161
+ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
162
+ handler: (request: Request, req?: unknown) => Promise<Response>;
150
163
  express: () => (req: http.IncomingMessage & {
151
164
  originalUrl?: string;
152
165
  }, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
@@ -156,13 +169,14 @@ declare function clearlyHub(options: ClearlyHubOptions): {
156
169
  getAppToken: () => Promise<string>;
157
170
  getSubscriptions: () => Promise<AppSubscription[]>;
158
171
  getSubscription(hubId: string): Promise<AppSubscription | null>;
159
- reportUsage({ request, hubId, quantity, }: {
160
- request: CookieSource;
172
+ reportUsage({ request, sub, hubId, quantity, }: {
173
+ request?: CookieSource;
174
+ sub?: string;
161
175
  hubId: string;
162
176
  quantity?: number;
163
177
  }): Promise<void>;
164
178
  };
165
179
  type ClearlyHub = ReturnType<typeof clearlyHub>;
166
180
 
167
- export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, clearlyHub, isScreenAction };
168
- export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
181
+ export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
182
+ export type { AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
package/dist/index.js CHANGED
@@ -8,6 +8,23 @@ var SCREEN_ACTIONS = [
8
8
  function isScreenAction(value) {
9
9
  return SCREEN_ACTIONS.includes(value);
10
10
  }
11
+ function checkScreenData(action, data) {
12
+ if (action !== "REGISTER_RESOURCE") return void 0;
13
+ const { url, format } = data ?? {};
14
+ if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
15
+ return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
16
+ }
17
+ let hostname;
18
+ try {
19
+ hostname = new URL(url).hostname;
20
+ } catch {
21
+ return `REGISTER_RESOURCE: "${url}" is not a URL`;
22
+ }
23
+ if (!hostname.includes(".")) {
24
+ return "REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost";
25
+ }
26
+ return void 0;
27
+ }
11
28
 
12
29
  // ../shared/src/protocol.ts
13
30
  var DEFAULT_BASE_PATH = "/clearly-hub";
@@ -55,7 +72,8 @@ function resolveConfig(options) {
55
72
  signedOutUrl: options.signedOutUrl ?? appUrl,
56
73
  onSignIn: options.onSignIn,
57
74
  onAnswer: options.onAnswer,
58
- fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
75
+ // Looked up on every call, so a fetch wrapped later (logging, tracing) is used
76
+ fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
59
77
  isSecure: appUrl.startsWith("https://")
60
78
  };
61
79
  }
@@ -70,7 +88,7 @@ function toNodeMiddleware(handle, appUrl) {
70
88
  else if (value !== void 0) headers.set(name, value);
71
89
  }
72
90
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
73
- const response = await handle(new Request(url, { method: req.method, headers }));
91
+ const response = await handle(new Request(url, { method: req.method, headers }), req);
74
92
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
75
93
  next();
76
94
  return;
@@ -170,18 +188,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
170
188
  var MAX_PENDING_SCREENS = 10;
171
189
  function createHandler(parts) {
172
190
  const { config, sessions } = parts;
173
- return async function handle(request) {
191
+ return async function handle(request, req) {
174
192
  const url = new URL(request.url);
175
193
  if (!url.pathname.startsWith(config.basePath)) return notFound();
176
194
  const route = url.pathname.slice(config.basePath.length) || "/";
177
195
  if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
196
+ const host = request.headers.get("host");
197
+ const appHost = new URL(config.appUrl).host;
198
+ if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
199
+ return redirect(`${config.appUrl}${url.pathname}${url.search}`);
200
+ }
178
201
  const { data, chunkCount } = await sessions.read(request);
179
202
  const before = JSON.stringify(data);
180
203
  let response;
181
204
  try {
182
- response = await routeRequest(route, url, data, parts);
205
+ response = await routeRequest(route, url, data, parts, { request, req });
183
206
  } catch (error) {
184
- response = errorPage(error instanceof AnswerError ? 400 : 500, error.message);
207
+ const isRefusedAnswer = error instanceof AnswerError;
208
+ if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
209
+ response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
185
210
  }
186
211
  if (JSON.stringify(data) !== before) {
187
212
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -191,12 +216,12 @@ function createHandler(parts) {
191
216
  return response;
192
217
  };
193
218
  }
194
- async function routeRequest(route, url, session, parts) {
219
+ async function routeRequest(route, url, session, parts, incoming) {
195
220
  switch (route) {
196
221
  case ROUTES.signIn:
197
222
  return startSignIn(url, session, parts);
198
223
  case ROUTES.callback:
199
- return finishSignIn(url, session, parts);
224
+ return finishSignIn(url, session, parts, incoming);
200
225
  case ROUTES.signOut:
201
226
  for (const key of Object.keys(session)) delete session[key];
202
227
  return redirect(parts.signIn.signOutUrl());
@@ -205,7 +230,7 @@ async function routeRequest(route, url, session, parts) {
205
230
  case ROUTES.open:
206
231
  return openScreen(url, session, parts);
207
232
  case ROUTES.answer:
208
- return receiveAnswer(url, session, parts);
233
+ return receiveAnswer(url, session, parts, incoming);
209
234
  case ROUTES.result: {
210
235
  const result = session.result ?? null;
211
236
  delete session.result;
@@ -220,16 +245,17 @@ async function startSignIn(url, session, { config, signIn }) {
220
245
  session.signIn = pending;
221
246
  return redirect(authorizationUrl);
222
247
  }
223
- async function finishSignIn(url, session, { config, signIn }) {
248
+ async function finishSignIn(url, session, { config, signIn }, incoming) {
224
249
  const pending = session.signIn;
225
250
  if (!pending) return redirect(config.appUrl);
226
251
  delete session.signIn;
227
252
  const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
228
253
  const { person, tokens } = await signIn.finish(currentUrl, pending);
229
- const appUserId = await config.onSignIn?.(person) ?? void 0;
254
+ const decision = await config.onSignIn?.(person, incoming);
255
+ const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
230
256
  session.user = { ...person, appUserId };
231
257
  session.tokens = tokens;
232
- return redirect(`${config.appUrl}${pending.returnTo}`);
258
+ return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
233
259
  }
234
260
  async function readSession(session, parts) {
235
261
  const accessToken = await getFreshAccessToken(session, parts);
@@ -255,6 +281,14 @@ function openScreen(url, session, { config, screens }) {
255
281
  if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
256
282
  const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
257
283
  const dataParam = url.searchParams.get("data");
284
+ let data;
285
+ try {
286
+ data = dataParam ? JSON.parse(dataParam) : void 0;
287
+ } catch {
288
+ return errorPage(400, "The screen data is not valid JSON");
289
+ }
290
+ const dataProblem = checkScreenData(action, data);
291
+ if (dataProblem) return errorPage(400, dataProblem);
258
292
  const state = crypto.randomUUID();
259
293
  session.screens = pruneScreens(session.screens);
260
294
  session.screens[state] = {
@@ -262,18 +296,12 @@ function openScreen(url, session, { config, screens }) {
262
296
  display,
263
297
  ref: url.searchParams.get("ref") ?? state,
264
298
  returnTo: safeReturnTo(url, config),
265
- createdAt: Date.now()
299
+ createdAt: Date.now(),
300
+ data
266
301
  };
267
- return redirect(
268
- screens.screenUrl(
269
- action,
270
- state,
271
- session.user?.email,
272
- dataParam ? JSON.parse(dataParam) : void 0
273
- )
274
- );
302
+ return redirect(screens.screenUrl(action, state, session.user?.email, data));
275
303
  }
276
- async function receiveAnswer(url, session, { config, screens }) {
304
+ async function receiveAnswer(url, session, { config, screens }, incoming) {
277
305
  const query = readRawQuery(url);
278
306
  const { state, response } = await screens.readAnswer(
279
307
  query.get("data") ?? "",
@@ -286,7 +314,12 @@ async function receiveAnswer(url, session, { config, screens }) {
286
314
  delete session.screens[state];
287
315
  let result = toScreenResult(pending.action, response);
288
316
  try {
289
- await config.onAnswer?.(result, { user: session.user });
317
+ const app = await config.onAnswer?.(result, {
318
+ ...incoming,
319
+ user: session.user,
320
+ data: pending.data
321
+ });
322
+ if (app !== void 0) result = { ...result, app };
290
323
  } catch (error) {
291
324
  result = { action: pending.action, result: "FAILURE", error: error.message };
292
325
  }
@@ -312,12 +345,14 @@ function pruneScreens(screens = {}) {
312
345
  return Object.fromEntries(fresh);
313
346
  }
314
347
  function safeReturnTo(url, config) {
315
- const returnTo = url.searchParams.get("returnTo") ?? "/";
316
- if (!returnTo.startsWith("/") || returnTo.startsWith("//")) return "/";
348
+ return safePath(url.searchParams.get("returnTo") ?? void 0, config) ?? "/";
349
+ }
350
+ function safePath(path, config) {
351
+ if (!path || !path.startsWith("/") || path.startsWith("//")) return void 0;
317
352
  try {
318
- return new URL(returnTo, config.appUrl).origin === new URL(config.appUrl).origin ? returnTo : "/";
353
+ return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
319
354
  } catch {
320
- return "/";
355
+ return void 0;
321
356
  }
322
357
  }
323
358
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
@@ -326,6 +361,13 @@ var json = (body) => new Response(JSON.stringify(body), {
326
361
  });
327
362
  var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
328
363
  var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
364
+ function describeError(error) {
365
+ const messages = [];
366
+ for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
367
+ if (!messages.includes(current.message)) messages.push(current.message);
368
+ }
369
+ return messages.join(": ") || String(error);
370
+ }
329
371
  function errorPage(status, message) {
330
372
  const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
331
373
  return html(
@@ -343,6 +385,15 @@ var HubApi = class {
343
385
  config;
344
386
  appToken;
345
387
  subscriptions;
388
+ // A call to Clearly.HUB. When it can't be reached at all, say where: "fetch
389
+ // failed" alone doesn't tell you which service isn't running
390
+ async call(url, init) {
391
+ try {
392
+ return await this.config.fetch(url, init);
393
+ } catch (error) {
394
+ throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
395
+ }
396
+ }
346
397
  get hasAppKey() {
347
398
  return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
348
399
  }
@@ -355,7 +406,7 @@ var HubApi = class {
355
406
  throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
356
407
  }
357
408
  const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
358
- const response = await this.config.fetch(`${this.config.urls.auth}/oauth2/token`, {
409
+ const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
359
410
  method: "POST",
360
411
  headers: {
361
412
  "Content-Type": "application/x-www-form-urlencoded",
@@ -378,7 +429,7 @@ var HubApi = class {
378
429
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
379
430
  return this.subscriptions.value;
380
431
  }
381
- const response = await this.config.fetch(`${this.config.urls.api}/api/app-api/subscriptions`, {
432
+ const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
382
433
  headers: { Authorization: `Bearer ${await this.getAppToken()}` }
383
434
  });
384
435
  if (!response.ok) {
@@ -398,7 +449,7 @@ var HubApi = class {
398
449
  }
399
450
  // Report one billable use; Clearly.HUB bills the hub
400
451
  async reportUsage(usage) {
401
- const response = await this.config.fetch(this.config.urls.usage, {
452
+ const response = await this.call(this.config.urls.usage, {
402
453
  method: "POST",
403
454
  headers: {
404
455
  "Content-Type": "application/json",
@@ -605,20 +656,25 @@ function clearlyHub(options) {
605
656
  async getSubscription(hubId) {
606
657
  return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
607
658
  },
608
- // Report billable use by the signed-in person in a hub
659
+ // Report billable use in a hub, by the person signed in with Clearly.HUB
660
+ // (request), or by a Clearly.HUB account you stored earlier (sub), for
661
+ // example for someone who signed in with your own login, or a nightly job
609
662
  async reportUsage({
610
663
  request,
664
+ sub,
611
665
  hubId,
612
666
  quantity = 1
613
667
  }) {
614
- const { data } = await parts.sessions.read(request);
615
- if (!data.user) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
668
+ const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
669
+ if (!person) {
670
+ throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
671
+ }
616
672
  const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
617
673
  if (subscription?.status !== "ACTIVE") {
618
674
  throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
619
675
  }
620
676
  await parts.api.reportUsage({
621
- sub: data.user.sub,
677
+ sub: person,
622
678
  hubId,
623
679
  subscriptionId: subscription.subscriptionId,
624
680
  quantity
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@clearly-hub/node",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Clearly.HUB for Node servers: sign-in, hosted screens, subscriptions and usage in one handler",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Future Insight Group BV",
7
- "keywords": ["clearly-hub", "oup", "open-urban-platform", "oidc", "sdk"],
7
+ "keywords": [
8
+ "clearly-hub",
9
+ "oup",
10
+ "open-urban-platform",
11
+ "oidc",
12
+ "sdk"
13
+ ],
8
14
  "type": "module",
9
15
  "main": "./dist/index.cjs",
10
16
  "module": "./dist/index.js",
@@ -33,7 +39,7 @@
33
39
  "openid-client": "^6.8.8"
34
40
  },
35
41
  "devDependencies": {
36
- "@clearly-hub/shared": "0.1.0"
42
+ "@clearly-hub/shared": "*"
37
43
  },
38
44
  "engines": {
39
45
  "node": ">=20"