@clearly-hub/node 0.1.1 → 0.2.1

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
 
@@ -55,18 +55,18 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
55
55
  </script>
56
56
  ```
57
57
 
58
- 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>`.
59
59
 
60
60
  ## The browser package
61
61
 
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 |
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 |
70
70
 
71
71
  **Styling the button.** It uses your page's font. Attributes for the common cases:
72
72
  `variant="outline"`, `size="small"` or `size="large"`. CSS custom properties for your look:
@@ -74,32 +74,51 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
74
74
  `--clearly-hub-font`. For anything else, style the real button through `::part(button)`:
75
75
 
76
76
  ```css
77
- clearly-hub-button { --clearly-hub-color: #0b7471; --clearly-hub-radius: 999px; }
78
- 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
+ }
79
84
  ```
80
85
 
81
86
  Screens open in a popup by default (`data-display="redirect"` on the script tag changes that). A
82
87
  blocked popup falls back to a redirect.
83
88
 
84
- | Action | Send in `data` | `answer` when `result` is `SUCCESS` |
85
- | --- | --- | --- |
86
- | `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
87
- | `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
88
- | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
89
- | `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`) |
90
95
 
91
96
  ## The Node package
92
97
 
93
- | | |
94
- | --- | --- |
95
- | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
96
- | `onSignIn(person)` | Called after each sign-in; return your own user id to keep it in the session |
97
- | `onAnswer(result, { user })` | Called with each checked answer before it reaches the browser, for example to set up a workspace |
98
- | `hub.getUser(req)` | Who's signed in, in your own routes |
99
- | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
100
- | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
101
- | `hub.reportUsage({ request, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub |
102
- | `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
+ ```
103
122
 
104
123
  **What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
105
124
  opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
@@ -112,7 +131,6 @@ store; it's split over several cookies because tokens are large.
112
131
 
113
132
  - Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
114
133
  `onAnswer` for `SELECT_SUBSCRIPTION`.
115
- - Child hubs in `session().hubs`: Clearly.HUB only returns root hubs today.
116
134
  - Other server languages: follow the Build checklist.
117
135
 
118
136
  ## Working on this repo
package/dist/index.cjs CHANGED
@@ -50,8 +50,14 @@ function checkScreenData(action, data) {
50
50
  if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
51
51
  return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
52
52
  }
53
- if (!url.startsWith("https://")) {
54
- return "REGISTER_RESOURCE needs a public https url: Clearly.HUB refuses other addresses";
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";
55
61
  }
56
62
  return void 0;
57
63
  }
@@ -102,7 +108,8 @@ function resolveConfig(options) {
102
108
  signedOutUrl: options.signedOutUrl ?? appUrl,
103
109
  onSignIn: options.onSignIn,
104
110
  onAnswer: options.onAnswer,
105
- 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)),
106
113
  isSecure: appUrl.startsWith("https://")
107
114
  };
108
115
  }
@@ -117,7 +124,7 @@ function toNodeMiddleware(handle, appUrl) {
117
124
  else if (value !== void 0) headers.set(name, value);
118
125
  }
119
126
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
120
- const response = await handle(new Request(url, { method: req.method, headers }));
127
+ const response = await handle(new Request(url, { method: req.method, headers }), req);
121
128
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
122
129
  next();
123
130
  return;
@@ -217,18 +224,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
217
224
  var MAX_PENDING_SCREENS = 10;
218
225
  function createHandler(parts) {
219
226
  const { config, sessions } = parts;
220
- return async function handle(request) {
227
+ return async function handle(request, req) {
221
228
  const url = new URL(request.url);
222
229
  if (!url.pathname.startsWith(config.basePath)) return notFound();
223
230
  const route = url.pathname.slice(config.basePath.length) || "/";
224
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
+ }
225
237
  const { data, chunkCount } = await sessions.read(request);
226
238
  const before = JSON.stringify(data);
227
239
  let response;
228
240
  try {
229
- response = await routeRequest(route, url, data, parts);
241
+ response = await routeRequest(route, url, data, parts, { request, req });
230
242
  } catch (error) {
231
- 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));
232
246
  }
233
247
  if (JSON.stringify(data) !== before) {
234
248
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -238,12 +252,12 @@ function createHandler(parts) {
238
252
  return response;
239
253
  };
240
254
  }
241
- async function routeRequest(route, url, session, parts) {
255
+ async function routeRequest(route, url, session, parts, incoming) {
242
256
  switch (route) {
243
257
  case ROUTES.signIn:
244
258
  return startSignIn(url, session, parts);
245
259
  case ROUTES.callback:
246
- return finishSignIn(url, session, parts);
260
+ return finishSignIn(url, session, parts, incoming);
247
261
  case ROUTES.signOut:
248
262
  for (const key of Object.keys(session)) delete session[key];
249
263
  return redirect(parts.signIn.signOutUrl());
@@ -252,7 +266,7 @@ async function routeRequest(route, url, session, parts) {
252
266
  case ROUTES.open:
253
267
  return openScreen(url, session, parts);
254
268
  case ROUTES.answer:
255
- return receiveAnswer(url, session, parts);
269
+ return receiveAnswer(url, session, parts, incoming);
256
270
  case ROUTES.result: {
257
271
  const result = session.result ?? null;
258
272
  delete session.result;
@@ -267,16 +281,17 @@ async function startSignIn(url, session, { config, signIn }) {
267
281
  session.signIn = pending;
268
282
  return redirect(authorizationUrl);
269
283
  }
270
- async function finishSignIn(url, session, { config, signIn }) {
284
+ async function finishSignIn(url, session, { config, signIn }, incoming) {
271
285
  const pending = session.signIn;
272
286
  if (!pending) return redirect(config.appUrl);
273
287
  delete session.signIn;
274
288
  const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
275
289
  const { person, tokens } = await signIn.finish(currentUrl, pending);
276
- 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 ?? {};
277
292
  session.user = { ...person, appUserId };
278
293
  session.tokens = tokens;
279
- return redirect(`${config.appUrl}${pending.returnTo}`);
294
+ return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
280
295
  }
281
296
  async function readSession(session, parts) {
282
297
  const accessToken = await getFreshAccessToken(session, parts);
@@ -317,11 +332,12 @@ function openScreen(url, session, { config, screens }) {
317
332
  display,
318
333
  ref: url.searchParams.get("ref") ?? state,
319
334
  returnTo: safeReturnTo(url, config),
320
- createdAt: Date.now()
335
+ createdAt: Date.now(),
336
+ data
321
337
  };
322
338
  return redirect(screens.screenUrl(action, state, session.user?.email, data));
323
339
  }
324
- async function receiveAnswer(url, session, { config, screens }) {
340
+ async function receiveAnswer(url, session, { config, screens }, incoming) {
325
341
  const query = readRawQuery(url);
326
342
  const { state, response } = await screens.readAnswer(
327
343
  query.get("data") ?? "",
@@ -334,7 +350,12 @@ async function receiveAnswer(url, session, { config, screens }) {
334
350
  delete session.screens[state];
335
351
  let result = toScreenResult(pending.action, response);
336
352
  try {
337
- 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 };
338
359
  } catch (error) {
339
360
  result = { action: pending.action, result: "FAILURE", error: error.message };
340
361
  }
@@ -360,12 +381,14 @@ function pruneScreens(screens = {}) {
360
381
  return Object.fromEntries(fresh);
361
382
  }
362
383
  function safeReturnTo(url, config) {
363
- const returnTo = url.searchParams.get("returnTo") ?? "/";
364
- 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;
365
388
  try {
366
- 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;
367
390
  } catch {
368
- return "/";
391
+ return void 0;
369
392
  }
370
393
  }
371
394
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
@@ -374,6 +397,13 @@ var json = (body) => new Response(JSON.stringify(body), {
374
397
  });
375
398
  var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
376
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
+ }
377
407
  function errorPage(status, message) {
378
408
  const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
379
409
  return html(
@@ -391,6 +421,15 @@ var HubApi = class {
391
421
  config;
392
422
  appToken;
393
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
+ }
394
433
  get hasAppKey() {
395
434
  return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
396
435
  }
@@ -403,7 +442,7 @@ var HubApi = class {
403
442
  throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
404
443
  }
405
444
  const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
406
- const response = await this.config.fetch(`${this.config.urls.auth}/oauth2/token`, {
445
+ const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
407
446
  method: "POST",
408
447
  headers: {
409
448
  "Content-Type": "application/x-www-form-urlencoded",
@@ -426,7 +465,7 @@ var HubApi = class {
426
465
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
427
466
  return this.subscriptions.value;
428
467
  }
429
- 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`, {
430
469
  headers: { Authorization: `Bearer ${await this.getAppToken()}` }
431
470
  });
432
471
  if (!response.ok) {
@@ -446,7 +485,7 @@ var HubApi = class {
446
485
  }
447
486
  // Report one billable use; Clearly.HUB bills the hub
448
487
  async reportUsage(usage) {
449
- const response = await this.config.fetch(this.config.urls.usage, {
488
+ const response = await this.call(this.config.urls.usage, {
450
489
  method: "POST",
451
490
  headers: {
452
491
  "Content-Type": "application/json",
@@ -653,20 +692,25 @@ function clearlyHub(options) {
653
692
  async getSubscription(hubId) {
654
693
  return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
655
694
  },
656
- // 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
657
698
  async reportUsage({
658
699
  request,
700
+ sub,
659
701
  hubId,
660
702
  quantity = 1
661
703
  }) {
662
- const { data } = await parts.sessions.read(request);
663
- 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
+ }
664
708
  const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
665
709
  if (subscription?.status !== "ACTIVE") {
666
710
  throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
667
711
  }
668
712
  await parts.api.reportUsage({
669
- sub: data.user.sub,
713
+ sub: person,
670
714
  hubId,
671
715
  subscriptionId: subscription.subscriptionId,
672
716
  quantity
package/dist/index.d.cts CHANGED
@@ -62,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
62
62
  action: A;
63
63
  result: 'SUCCESS';
64
64
  answer: ScreenAnswers[A];
65
+ app?: unknown;
65
66
  } | {
66
67
  action: A;
67
68
  result: 'CANCELLED' | 'FAILURE';
68
69
  answer?: undefined;
69
70
  error?: string;
71
+ app?: unknown;
70
72
  };
71
73
 
72
74
  interface SessionUser {
@@ -112,7 +114,19 @@ interface SignedInPerson {
112
114
  email: string;
113
115
  name?: string;
114
116
  }
115
- 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> {
116
130
  clientId: string;
117
131
  m2mClientId?: string;
118
132
  m2mClientSecret?: string;
@@ -122,10 +136,8 @@ interface ClearlyHubOptions {
122
136
  hubUrl?: string;
123
137
  urls?: Partial<ClearlyHubUrls>;
124
138
  signedOutUrl?: string;
125
- onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
126
- onAnswer?: (result: ScreenResult, context: {
127
- user?: SessionUser;
128
- }) => 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;
129
141
  fetch?: typeof fetch;
130
142
  }
131
143
  interface ClearlyHubUrls {
@@ -146,8 +158,8 @@ type CookieSource = Request | {
146
158
  headers: Record<string, string | string[] | undefined>;
147
159
  };
148
160
 
149
- declare function clearlyHub(options: ClearlyHubOptions): {
150
- handler: (request: Request) => Promise<Response>;
161
+ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
162
+ handler: (request: Request, req?: unknown) => Promise<Response>;
151
163
  express: () => (req: http.IncomingMessage & {
152
164
  originalUrl?: string;
153
165
  }, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
@@ -157,8 +169,9 @@ declare function clearlyHub(options: ClearlyHubOptions): {
157
169
  getAppToken: () => Promise<string>;
158
170
  getSubscriptions: () => Promise<AppSubscription[]>;
159
171
  getSubscription(hubId: string): Promise<AppSubscription | null>;
160
- reportUsage({ request, hubId, quantity, }: {
161
- request: CookieSource;
172
+ reportUsage({ request, sub, hubId, quantity, }: {
173
+ request?: CookieSource;
174
+ sub?: string;
162
175
  hubId: string;
163
176
  quantity?: number;
164
177
  }): Promise<void>;
@@ -166,4 +179,4 @@ declare function clearlyHub(options: ClearlyHubOptions): {
166
179
  type ClearlyHub = ReturnType<typeof clearlyHub>;
167
180
 
168
181
  export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
169
- export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
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
@@ -62,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
62
62
  action: A;
63
63
  result: 'SUCCESS';
64
64
  answer: ScreenAnswers[A];
65
+ app?: unknown;
65
66
  } | {
66
67
  action: A;
67
68
  result: 'CANCELLED' | 'FAILURE';
68
69
  answer?: undefined;
69
70
  error?: string;
71
+ app?: unknown;
70
72
  };
71
73
 
72
74
  interface SessionUser {
@@ -112,7 +114,19 @@ interface SignedInPerson {
112
114
  email: string;
113
115
  name?: string;
114
116
  }
115
- 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> {
116
130
  clientId: string;
117
131
  m2mClientId?: string;
118
132
  m2mClientSecret?: string;
@@ -122,10 +136,8 @@ interface ClearlyHubOptions {
122
136
  hubUrl?: string;
123
137
  urls?: Partial<ClearlyHubUrls>;
124
138
  signedOutUrl?: string;
125
- onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
126
- onAnswer?: (result: ScreenResult, context: {
127
- user?: SessionUser;
128
- }) => 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;
129
141
  fetch?: typeof fetch;
130
142
  }
131
143
  interface ClearlyHubUrls {
@@ -146,8 +158,8 @@ type CookieSource = Request | {
146
158
  headers: Record<string, string | string[] | undefined>;
147
159
  };
148
160
 
149
- declare function clearlyHub(options: ClearlyHubOptions): {
150
- handler: (request: Request) => Promise<Response>;
161
+ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
162
+ handler: (request: Request, req?: unknown) => Promise<Response>;
151
163
  express: () => (req: http.IncomingMessage & {
152
164
  originalUrl?: string;
153
165
  }, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
@@ -157,8 +169,9 @@ declare function clearlyHub(options: ClearlyHubOptions): {
157
169
  getAppToken: () => Promise<string>;
158
170
  getSubscriptions: () => Promise<AppSubscription[]>;
159
171
  getSubscription(hubId: string): Promise<AppSubscription | null>;
160
- reportUsage({ request, hubId, quantity, }: {
161
- request: CookieSource;
172
+ reportUsage({ request, sub, hubId, quantity, }: {
173
+ request?: CookieSource;
174
+ sub?: string;
162
175
  hubId: string;
163
176
  quantity?: number;
164
177
  }): Promise<void>;
@@ -166,4 +179,4 @@ declare function clearlyHub(options: ClearlyHubOptions): {
166
179
  type ClearlyHub = ReturnType<typeof clearlyHub>;
167
180
 
168
181
  export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
169
- export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
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
@@ -14,8 +14,14 @@ function checkScreenData(action, data) {
14
14
  if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
15
15
  return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
16
16
  }
17
- if (!url.startsWith("https://")) {
18
- return "REGISTER_RESOURCE needs a public https url: Clearly.HUB refuses other addresses";
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";
19
25
  }
20
26
  return void 0;
21
27
  }
@@ -66,7 +72,8 @@ function resolveConfig(options) {
66
72
  signedOutUrl: options.signedOutUrl ?? appUrl,
67
73
  onSignIn: options.onSignIn,
68
74
  onAnswer: options.onAnswer,
69
- 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)),
70
77
  isSecure: appUrl.startsWith("https://")
71
78
  };
72
79
  }
@@ -81,7 +88,7 @@ function toNodeMiddleware(handle, appUrl) {
81
88
  else if (value !== void 0) headers.set(name, value);
82
89
  }
83
90
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
84
- const response = await handle(new Request(url, { method: req.method, headers }));
91
+ const response = await handle(new Request(url, { method: req.method, headers }), req);
85
92
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
86
93
  next();
87
94
  return;
@@ -181,18 +188,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
181
188
  var MAX_PENDING_SCREENS = 10;
182
189
  function createHandler(parts) {
183
190
  const { config, sessions } = parts;
184
- return async function handle(request) {
191
+ return async function handle(request, req) {
185
192
  const url = new URL(request.url);
186
193
  if (!url.pathname.startsWith(config.basePath)) return notFound();
187
194
  const route = url.pathname.slice(config.basePath.length) || "/";
188
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
+ }
189
201
  const { data, chunkCount } = await sessions.read(request);
190
202
  const before = JSON.stringify(data);
191
203
  let response;
192
204
  try {
193
- response = await routeRequest(route, url, data, parts);
205
+ response = await routeRequest(route, url, data, parts, { request, req });
194
206
  } catch (error) {
195
- 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));
196
210
  }
197
211
  if (JSON.stringify(data) !== before) {
198
212
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -202,12 +216,12 @@ function createHandler(parts) {
202
216
  return response;
203
217
  };
204
218
  }
205
- async function routeRequest(route, url, session, parts) {
219
+ async function routeRequest(route, url, session, parts, incoming) {
206
220
  switch (route) {
207
221
  case ROUTES.signIn:
208
222
  return startSignIn(url, session, parts);
209
223
  case ROUTES.callback:
210
- return finishSignIn(url, session, parts);
224
+ return finishSignIn(url, session, parts, incoming);
211
225
  case ROUTES.signOut:
212
226
  for (const key of Object.keys(session)) delete session[key];
213
227
  return redirect(parts.signIn.signOutUrl());
@@ -216,7 +230,7 @@ async function routeRequest(route, url, session, parts) {
216
230
  case ROUTES.open:
217
231
  return openScreen(url, session, parts);
218
232
  case ROUTES.answer:
219
- return receiveAnswer(url, session, parts);
233
+ return receiveAnswer(url, session, parts, incoming);
220
234
  case ROUTES.result: {
221
235
  const result = session.result ?? null;
222
236
  delete session.result;
@@ -231,16 +245,17 @@ async function startSignIn(url, session, { config, signIn }) {
231
245
  session.signIn = pending;
232
246
  return redirect(authorizationUrl);
233
247
  }
234
- async function finishSignIn(url, session, { config, signIn }) {
248
+ async function finishSignIn(url, session, { config, signIn }, incoming) {
235
249
  const pending = session.signIn;
236
250
  if (!pending) return redirect(config.appUrl);
237
251
  delete session.signIn;
238
252
  const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
239
253
  const { person, tokens } = await signIn.finish(currentUrl, pending);
240
- 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 ?? {};
241
256
  session.user = { ...person, appUserId };
242
257
  session.tokens = tokens;
243
- return redirect(`${config.appUrl}${pending.returnTo}`);
258
+ return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
244
259
  }
245
260
  async function readSession(session, parts) {
246
261
  const accessToken = await getFreshAccessToken(session, parts);
@@ -281,11 +296,12 @@ function openScreen(url, session, { config, screens }) {
281
296
  display,
282
297
  ref: url.searchParams.get("ref") ?? state,
283
298
  returnTo: safeReturnTo(url, config),
284
- createdAt: Date.now()
299
+ createdAt: Date.now(),
300
+ data
285
301
  };
286
302
  return redirect(screens.screenUrl(action, state, session.user?.email, data));
287
303
  }
288
- async function receiveAnswer(url, session, { config, screens }) {
304
+ async function receiveAnswer(url, session, { config, screens }, incoming) {
289
305
  const query = readRawQuery(url);
290
306
  const { state, response } = await screens.readAnswer(
291
307
  query.get("data") ?? "",
@@ -298,7 +314,12 @@ async function receiveAnswer(url, session, { config, screens }) {
298
314
  delete session.screens[state];
299
315
  let result = toScreenResult(pending.action, response);
300
316
  try {
301
- 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 };
302
323
  } catch (error) {
303
324
  result = { action: pending.action, result: "FAILURE", error: error.message };
304
325
  }
@@ -324,12 +345,14 @@ function pruneScreens(screens = {}) {
324
345
  return Object.fromEntries(fresh);
325
346
  }
326
347
  function safeReturnTo(url, config) {
327
- const returnTo = url.searchParams.get("returnTo") ?? "/";
328
- 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;
329
352
  try {
330
- 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;
331
354
  } catch {
332
- return "/";
355
+ return void 0;
333
356
  }
334
357
  }
335
358
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
@@ -338,6 +361,13 @@ var json = (body) => new Response(JSON.stringify(body), {
338
361
  });
339
362
  var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
340
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
+ }
341
371
  function errorPage(status, message) {
342
372
  const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
343
373
  return html(
@@ -355,6 +385,15 @@ var HubApi = class {
355
385
  config;
356
386
  appToken;
357
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
+ }
358
397
  get hasAppKey() {
359
398
  return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
360
399
  }
@@ -367,7 +406,7 @@ var HubApi = class {
367
406
  throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
368
407
  }
369
408
  const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
370
- const response = await this.config.fetch(`${this.config.urls.auth}/oauth2/token`, {
409
+ const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
371
410
  method: "POST",
372
411
  headers: {
373
412
  "Content-Type": "application/x-www-form-urlencoded",
@@ -390,7 +429,7 @@ var HubApi = class {
390
429
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
391
430
  return this.subscriptions.value;
392
431
  }
393
- 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`, {
394
433
  headers: { Authorization: `Bearer ${await this.getAppToken()}` }
395
434
  });
396
435
  if (!response.ok) {
@@ -410,7 +449,7 @@ var HubApi = class {
410
449
  }
411
450
  // Report one billable use; Clearly.HUB bills the hub
412
451
  async reportUsage(usage) {
413
- const response = await this.config.fetch(this.config.urls.usage, {
452
+ const response = await this.call(this.config.urls.usage, {
414
453
  method: "POST",
415
454
  headers: {
416
455
  "Content-Type": "application/json",
@@ -617,20 +656,25 @@ function clearlyHub(options) {
617
656
  async getSubscription(hubId) {
618
657
  return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
619
658
  },
620
- // 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
621
662
  async reportUsage({
622
663
  request,
664
+ sub,
623
665
  hubId,
624
666
  quantity = 1
625
667
  }) {
626
- const { data } = await parts.sessions.read(request);
627
- 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
+ }
628
672
  const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
629
673
  if (subscription?.status !== "ACTIVE") {
630
674
  throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
631
675
  }
632
676
  await parts.api.reportUsage({
633
- sub: data.user.sub,
677
+ sub: person,
634
678
  hubId,
635
679
  subscriptionId: subscription.subscriptionId,
636
680
  quantity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearly-hub/node",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",