@clearly-hub/node 0.3.0 → 0.4.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
@@ -2,7 +2,8 @@
2
2
 
3
3
  Connect your web app to Clearly.HUB with a script, some config and one server handler. You get
4
4
  "Sign in with Clearly.HUB", the hosted screens (choose a plan, pick data, save a result, choose a
5
- hub) and each hub's subscription to your app. Tokens stay on your server.
5
+ hub) and each hub's subscription to your app. The refresh token stays on your server; the page gets
6
+ the access token only when it asks, for protected data.
6
7
 
7
8
  > **Preview (0.x).** The API may still change between minor versions. Proprietary: see
8
9
  > [LICENSE](LICENSE).
@@ -30,7 +31,7 @@ const hub = clearlyHub({
30
31
  m2mClientId: process.env.CLEARLY_HUB_M2M_CLIENT_ID,
31
32
  m2mClientSecret: process.env.CLEARLY_HUB_M2M_CLIENT_SECRET,
32
33
  appUrl: 'https://app.example.com',
33
- secret: process.env.SESSION_SECRET, // 32+ characters
34
+ secret: process.env.SESSION_SECRET, // 32+ random characters
34
35
  });
35
36
 
36
37
  const app = express();
@@ -51,7 +52,9 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
51
52
 
52
53
  <script type="module">
53
54
  const session = await clearlyHub.session(); // { signedIn, user }
54
- window.addEventListener('clearly-hub-answer', (event) => console.log(event.detail));
55
+ window.addEventListener('clearly-hub-answer', (event) =>
56
+ console.log(event.detail),
57
+ );
55
58
  </script>
56
59
  ```
57
60
 
@@ -65,6 +68,7 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
65
68
  | `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
66
69
  | `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
70
  | `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
71
+ | `clearlyHub.getToken()` | The person's key for protected and paid data, or `null` when signed out. See [Protected data](#protected-data) |
68
72
  | `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 |
69
73
  | `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
70
74
  | `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
@@ -94,15 +98,34 @@ blocked popup falls back to a redirect.
94
98
  | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
95
99
  | `SELECT_HUB` | | `hub` (`_id`, `name`) |
96
100
 
101
+ ## Protected data
102
+
103
+ A protected or paid resource from `DATA_PICKER` (`requiresToken: true`) has a `url` on Clearly.HUB,
104
+ which checks the person's key on every request. Send it as the `Authorization` header. In the page:
105
+
106
+ ```js
107
+ const response = await fetch(resource.url, {
108
+ headers: { Authorization: await clearlyHub.getToken() },
109
+ });
110
+ ```
111
+
112
+ Map layers (WMS, WMTS, tiles) load many URLs themselves: use a Leaflet, OpenLayers or Cesium option
113
+ or plugin that adds headers to each request. The key lasts an hour; `getToken()` gets a new one in
114
+ time, so call it again rather than keeping the value. On your server, `hub.fetchAsUser(req, url)`
115
+ does the same.
116
+
117
+ The key is the person's own Clearly.HUB key: send it only to resource URLs from `DATA_PICKER` (Clearly.HUB's
118
+ data proxy), and keep scripts you don't trust off pages that use it. `fetchAsUser` refuses other URLs.
119
+
97
120
  ## The Node package
98
121
 
99
122
  | | |
100
123
  | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
- | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
124
+ | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result`, `/token` |
102
125
  | `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 |
103
126
  | `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` |
104
127
  | `hub.getUser(req)` | Who's signed in, in your own routes |
105
- | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
128
+ | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) on your server, with the person's key. Only URLs on Clearly.HUB's data proxy |
106
129
  | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
107
130
  | `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 |
108
131
  | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
@@ -121,18 +144,29 @@ const hub = clearlyHub<express.Request>({
121
144
  });
122
145
  ```
123
146
 
124
- **What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
125
- opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
126
- store; it's split over several cookies because tokens are large.
147
+ **What it checks for you:** every screen answer's Ed25519 signature, that it answers a screen
148
+ opened in this browser, once (`state`), and that it was given to your app and that screen
149
+ (`client_id`, callback URL, action).
150
+
151
+ **What to trust in an answer:** it's a snapshot of the moment the person clicked. For access later,
152
+ ask `getSubscription(hubId)` when it's used, not the `status` you got then. Ignore `answer.user`:
153
+ Clearly.HUB doesn't check it yet. Who's signed in is the session's user.
154
+
155
+ The session is encrypted in cookies, so there's nothing to store; it's split over several cookies
156
+ because tokens are large. A copied cookie stays valid until its refresh token expires: signing out
157
+ clears only this browser's copy.
127
158
 
128
- **Local Clearly.HUB:** pass `urls: { screens, api, usage, auth, issuer }`; see
159
+ **Local Clearly.HUB:** pass `urls: { screens, api, proxy, auth, issuer }`; see
129
160
  `examples/express/.env.example`.
130
161
 
131
162
  ## Not covered (yet)
132
163
 
133
164
  - Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
134
165
  `onAnswer` for `SELECT_SUBSCRIPTION`.
135
- - Other server languages: follow the Build checklist.
166
+ - Other server languages (.NET, Python, ...): the browser package works with any
167
+ backend that answers the same routes. Contact us at
168
+ [support@futureinsight.nl](mailto:support@futureinsight.nl) and we'll help
169
+ you with yours.
136
170
 
137
171
  ## Working on this repo
138
172
 
package/dist/index.cjs CHANGED
@@ -34,6 +34,23 @@ __export(index_exports, {
34
34
  });
35
35
  module.exports = __toCommonJS(index_exports);
36
36
 
37
+ // ../shared/src/protocol.ts
38
+ var DEFAULT_BASE_PATH = "/clearly-hub";
39
+ var DEFAULT_HUB_URL = "https://hub.clearly.app";
40
+ var DEFAULT_AUTH_URL = "https://auth.clearly.app";
41
+ var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
42
+ var ROUTES = {
43
+ signIn: "/sign-in",
44
+ callback: "/callback",
45
+ signOut: "/sign-out",
46
+ session: "/session",
47
+ open: "/open",
48
+ answer: "/answer",
49
+ result: "/result",
50
+ token: "/token"
51
+ };
52
+ var ANSWER_CHANNEL = "clearly-hub-answers";
53
+
37
54
  // ../shared/src/screens.ts
38
55
  var SCREEN_ACTIONS = [
39
56
  "SELECT_SUBSCRIPTION",
@@ -62,22 +79,6 @@ function checkScreenData(action, data) {
62
79
  return void 0;
63
80
  }
64
81
 
65
- // ../shared/src/protocol.ts
66
- var DEFAULT_BASE_PATH = "/clearly-hub";
67
- var DEFAULT_HUB_URL = "https://hub.clearly.app";
68
- var DEFAULT_AUTH_URL = "https://auth.clearly.app";
69
- var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
70
- var ROUTES = {
71
- signIn: "/sign-in",
72
- callback: "/callback",
73
- signOut: "/sign-out",
74
- session: "/session",
75
- open: "/open",
76
- answer: "/answer",
77
- result: "/result"
78
- };
79
- var ANSWER_CHANNEL = "clearly-hub-answers";
80
-
81
82
  // src/config.ts
82
83
  var trimSlash = (url) => url.replace(/\/+$/, "");
83
84
  function resolveConfig(options) {
@@ -89,6 +90,7 @@ function resolveConfig(options) {
89
90
  const appUrl = trimSlash(options.appUrl);
90
91
  const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
91
92
  const api = trimSlash(options.urls?.api ?? hubUrl);
93
+ const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
92
94
  return {
93
95
  clientId: options.clientId,
94
96
  m2mClientId: options.m2mClientId,
@@ -100,7 +102,8 @@ function resolveConfig(options) {
100
102
  urls: {
101
103
  screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
102
104
  api,
103
- usage: options.urls?.usage ?? `${hubUrl}/register-usage`,
105
+ proxy,
106
+ usage: options.urls?.usage ?? `${proxy}/register-usage`,
104
107
  publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
105
108
  auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
106
109
  issuer: options.urls?.issuer ?? DEFAULT_ISSUER
@@ -120,11 +123,15 @@ function toNodeMiddleware(handle, appUrl) {
120
123
  try {
121
124
  const headers = new Headers();
122
125
  for (const [name, value] of Object.entries(req.headers)) {
123
- if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
126
+ if (Array.isArray(value))
127
+ value.forEach((item) => headers.append(name, item));
124
128
  else if (value !== void 0) headers.set(name, value);
125
129
  }
126
130
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
127
- const response = await handle(new Request(url, { method: req.method, headers }), req);
131
+ const response = await handle(
132
+ new Request(url, { method: req.method, headers }),
133
+ req
134
+ );
128
135
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
129
136
  next();
130
137
  return;
@@ -178,16 +185,24 @@ var Screens = class {
178
185
  base64Decode(signature),
179
186
  new TextEncoder().encode(data)
180
187
  );
181
- if (!isSigned) throw new AnswerError("This answer was not signed by Clearly.HUB");
182
- const { request, response } = JSON.parse(new TextDecoder().decode(base64Decode(data)));
183
- return { state: request.state, response };
188
+ if (!isSigned)
189
+ throw new AnswerError("This answer was not signed by Clearly.HUB");
190
+ const { request, response } = JSON.parse(
191
+ new TextDecoder().decode(base64Decode(data))
192
+ );
193
+ return { request: request ?? {}, response };
184
194
  }
185
195
  // Fetched once; a key change needs a restart
186
196
  getPublicKey() {
187
197
  this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
188
- if (!response.ok) throw new Error(`No public key at ${this.config.urls.publicKey}`);
198
+ if (!response.ok)
199
+ throw new Error(`No public key at ${this.config.urls.publicKey}`);
189
200
  return response.text();
190
- }).then((pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, ["verify"])).catch((error) => {
201
+ }).then(
202
+ (pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
203
+ "verify"
204
+ ])
205
+ ).catch((error) => {
191
206
  this.publicKey = void 0;
192
207
  throw error;
193
208
  });
@@ -198,7 +213,8 @@ var AnswerError = class extends Error {
198
213
  };
199
214
  function toScreenResult(action, response) {
200
215
  const { result, ...answer } = response;
201
- if (result === "SUCCESS") return { action, result: "SUCCESS", answer };
216
+ if (result === "SUCCESS")
217
+ return { action, result: "SUCCESS", answer };
202
218
  return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
203
219
  }
204
220
  function base64Encode(text) {
@@ -214,7 +230,11 @@ function readRawQuery(url) {
214
230
  const params = /* @__PURE__ */ new Map();
215
231
  for (const pair of url.search.slice(1).split("&")) {
216
232
  const index = pair.indexOf("=");
217
- if (index > 0) params.set(pair.slice(0, index), decodeURIComponent(pair.slice(index + 1)));
233
+ if (index > 0)
234
+ params.set(
235
+ pair.slice(0, index),
236
+ decodeURIComponent(pair.slice(index + 1))
237
+ );
218
238
  }
219
239
  return params;
220
240
  }
@@ -228,7 +248,8 @@ function createHandler(parts) {
228
248
  const url = new URL(request.url);
229
249
  if (!url.pathname.startsWith(config.basePath)) return notFound();
230
250
  const route = url.pathname.slice(config.basePath.length) || "/";
231
- if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
251
+ if (request.method !== "GET")
252
+ return new Response("Method not allowed", { status: 405 });
232
253
  const host = request.headers.get("host");
233
254
  const appHost = new URL(config.appUrl).host;
234
255
  if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
@@ -241,8 +262,12 @@ function createHandler(parts) {
241
262
  response = await routeRequest(route, url, data, parts, { request, req });
242
263
  } catch (error) {
243
264
  const isRefusedAnswer = error instanceof AnswerError;
244
- if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
245
- response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
265
+ if (!isRefusedAnswer)
266
+ console.error(`Clearly.HUB: ${route} failed:`, error);
267
+ response = isRefusedAnswer ? errorPage(400, error.message) : errorPage(
268
+ 500,
269
+ config.isSecure ? "Something went wrong with Clearly.HUB. Please try again." : describeError(error)
270
+ );
246
271
  }
247
272
  if (JSON.stringify(data) !== before) {
248
273
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -259,7 +284,8 @@ async function routeRequest(route, url, session, parts, incoming) {
259
284
  case ROUTES.callback:
260
285
  return finishSignIn(url, session, parts, incoming);
261
286
  case ROUTES.signOut:
262
- for (const key of Object.keys(session)) delete session[key];
287
+ for (const key of Object.keys(session))
288
+ delete session[key];
263
289
  return redirect(parts.signIn.signOutUrl());
264
290
  case ROUTES.session:
265
291
  return json(await readSession(session, parts));
@@ -272,12 +298,16 @@ async function routeRequest(route, url, session, parts, incoming) {
272
298
  delete session.result;
273
299
  return json(result);
274
300
  }
301
+ case ROUTES.token:
302
+ return readToken(session, parts);
275
303
  default:
276
304
  return notFound();
277
305
  }
278
306
  }
279
307
  async function startSignIn(url, session, { config, signIn }) {
280
- const { url: authorizationUrl, pending } = await signIn.start(safeReturnTo(url, config));
308
+ const { url: authorizationUrl, pending } = await signIn.start(
309
+ safeReturnTo(url, config)
310
+ );
281
311
  session.signIn = pending;
282
312
  return redirect(authorizationUrl);
283
313
  }
@@ -291,13 +321,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
291
321
  const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
292
322
  session.user = { ...person, appUserId };
293
323
  session.tokens = tokens;
294
- return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
324
+ return redirect(
325
+ `${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
326
+ );
295
327
  }
296
328
  async function readSession(session, parts) {
297
329
  const accessToken = await getFreshAccessToken(session, parts);
298
330
  if (!session.user || !accessToken) return { signedIn: false };
299
331
  return { signedIn: true, user: session.user };
300
332
  }
333
+ async function readToken(session, parts) {
334
+ const accessToken = await getFreshAccessToken(session, parts);
335
+ if (!session.user || !accessToken)
336
+ return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
337
+ const token = {
338
+ accessToken,
339
+ expiresIn: Math.max(
340
+ 0,
341
+ session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
342
+ )
343
+ };
344
+ return json(token);
345
+ }
301
346
  async function getFreshAccessToken(session, { signIn }) {
302
347
  const tokens = session.tokens;
303
348
  if (!tokens) return void 0;
@@ -314,7 +359,8 @@ async function getFreshAccessToken(session, { signIn }) {
314
359
  }
315
360
  function openScreen(url, session, { config, screens }) {
316
361
  const action = url.searchParams.get("action");
317
- if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
362
+ if (!isScreenAction(action))
363
+ return errorPage(400, `Unknown screen: ${action}`);
318
364
  const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
319
365
  const dataParam = url.searchParams.get("data");
320
366
  let data;
@@ -339,15 +385,21 @@ function openScreen(url, session, { config, screens }) {
339
385
  }
340
386
  async function receiveAnswer(url, session, { config, screens }, incoming) {
341
387
  const query = readRawQuery(url);
342
- const { state, response } = await screens.readAnswer(
388
+ const { request, response } = await screens.readAnswer(
343
389
  query.get("data") ?? "",
344
390
  query.get("signature") ?? ""
345
391
  );
392
+ const state = request.state;
346
393
  const pending = state ? session.screens?.[state] : void 0;
347
394
  if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
348
- throw new AnswerError("This answer does not belong to a screen opened in this browser");
395
+ throw new AnswerError(
396
+ "This answer does not belong to a screen opened in this browser"
397
+ );
349
398
  }
350
399
  delete session.screens[state];
400
+ if (request.client_id !== config.clientId || request.redirect_url !== screens.answerUrl || request.actions?.length !== 1 || request.actions[0] !== pending.action) {
401
+ throw new AnswerError("This answer was given to another app or screen");
402
+ }
351
403
  let result = toScreenResult(pending.action, response);
352
404
  try {
353
405
  const app = await config.onAnswer?.(result, {
@@ -357,7 +409,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
357
409
  });
358
410
  if (app !== void 0) result = { ...result, app };
359
411
  } catch (error) {
360
- result = { action: pending.action, result: "FAILURE", error: error.message };
412
+ result = {
413
+ action: pending.action,
414
+ result: "FAILURE",
415
+ error: error.message
416
+ };
361
417
  }
362
418
  if (pending.display === "redirect") {
363
419
  session.result = { ref: pending.ref, result };
@@ -392,11 +448,21 @@ function safePath(path, config) {
392
448
  }
393
449
  }
394
450
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
395
- var json = (body) => new Response(JSON.stringify(body), {
396
- headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
451
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
452
+ status,
453
+ headers: {
454
+ "Content-Type": "application/json",
455
+ "Cache-Control": "no-store"
456
+ }
457
+ });
458
+ var html = (body, status = 200) => new Response(body, {
459
+ status,
460
+ headers: { "Content-Type": "text/html; charset=utf-8" }
461
+ });
462
+ var notFound = () => new Response("Not found", {
463
+ status: 404,
464
+ headers: { "X-Clearly-Hub": "unhandled" }
397
465
  });
398
- var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
399
- var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
400
466
  function describeError(error) {
401
467
  const messages = [];
402
468
  for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
@@ -405,7 +471,10 @@ function describeError(error) {
405
471
  return messages.join(": ") || String(error);
406
472
  }
407
473
  function errorPage(status, message) {
408
- const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
474
+ const escaped = message.replace(
475
+ /[&<>"]/g,
476
+ (char) => `&#${char.charCodeAt(0)};`
477
+ );
409
478
  return html(
410
479
  `<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
411
480
  status
@@ -427,7 +496,9 @@ var HubApi = class {
427
496
  try {
428
497
  return await this.config.fetch(url, init);
429
498
  } catch (error) {
430
- throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
499
+ throw new Error(`Clearly.HUB could not be reached at ${url}`, {
500
+ cause: error
501
+ });
431
502
  }
432
503
  }
433
504
  get hasAppKey() {
@@ -437,11 +508,16 @@ var HubApi = class {
437
508
  // until shortly before it expires
438
509
  async getAppToken() {
439
510
  const now = Date.now() / 1e3;
440
- if (this.appToken && this.appToken.expiresAt > now + 60) return this.appToken.value;
511
+ if (this.appToken && this.appToken.expiresAt > now + 60)
512
+ return this.appToken.value;
441
513
  if (!this.hasAppKey) {
442
- throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
514
+ throw new Error(
515
+ "Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
516
+ );
443
517
  }
444
- const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
518
+ const credentials = btoa(
519
+ `${this.config.m2mClientId}:${this.config.m2mClientSecret}`
520
+ );
445
521
  const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
446
522
  method: "POST",
447
523
  headers: {
@@ -457,7 +533,10 @@ var HubApi = class {
457
533
  throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
458
534
  }
459
535
  const body = await response.json();
460
- this.appToken = { value: body.access_token, expiresAt: now + body.expires_in };
536
+ this.appToken = {
537
+ value: body.access_token,
538
+ expiresAt: now + body.expires_in
539
+ };
461
540
  return body.access_token;
462
541
  }
463
542
  // Every hub that bought your app, with status and your plan id
@@ -465,11 +544,16 @@ var HubApi = class {
465
544
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
466
545
  return this.subscriptions.value;
467
546
  }
468
- const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
469
- headers: { Authorization: `Bearer ${await this.getAppToken()}` }
470
- });
547
+ const response = await this.call(
548
+ `${this.config.urls.api}/api/app-api/subscriptions`,
549
+ {
550
+ headers: { Authorization: `Bearer ${await this.getAppToken()}` }
551
+ }
552
+ );
471
553
  if (!response.ok) {
472
- throw new Error(`Could not read the subscriptions from Clearly.HUB (${response.status})`);
554
+ throw new Error(
555
+ `Could not read the subscriptions from Clearly.HUB (${response.status})`
556
+ );
473
557
  }
474
558
  const items = await response.json();
475
559
  const value = items.map((item) => ({
@@ -502,12 +586,16 @@ var HubApi = class {
502
586
  });
503
587
  if (!response.ok) {
504
588
  const body = await response.json().catch(() => ({}));
505
- throw new Error(`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`);
589
+ throw new Error(
590
+ `Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
591
+ );
506
592
  }
507
593
  }
508
594
  };
509
595
  function pickSubscription(subscriptions, hubId) {
510
- const ofHub = subscriptions.filter((subscription) => subscription.hubId === hubId);
596
+ const ofHub = subscriptions.filter(
597
+ (subscription) => subscription.hubId === hubId
598
+ );
511
599
  return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
512
600
  }
513
601
 
@@ -524,7 +612,12 @@ var SignIn = class {
524
612
  }
525
613
  // Discovery happens once, on the first sign-in
526
614
  getConfiguration() {
527
- this.configuration ??= oidc.discovery(new URL(this.config.urls.issuer), this.config.clientId, void 0, oidc.None()).catch((error) => {
615
+ this.configuration ??= oidc.discovery(
616
+ new URL(this.config.urls.issuer),
617
+ this.config.clientId,
618
+ void 0,
619
+ oidc.None()
620
+ ).catch((error) => {
528
621
  this.configuration = void 0;
529
622
  throw error;
530
623
  });
@@ -546,11 +639,15 @@ var SignIn = class {
546
639
  return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
547
640
  }
548
641
  async finish(currentUrl, pending) {
549
- const tokens = await oidc.authorizationCodeGrant(await this.getConfiguration(), currentUrl, {
550
- pkceCodeVerifier: pending.codeVerifier,
551
- expectedState: pending.state,
552
- expectedNonce: pending.nonce
553
- });
642
+ const tokens = await oidc.authorizationCodeGrant(
643
+ await this.getConfiguration(),
644
+ currentUrl,
645
+ {
646
+ pkceCodeVerifier: pending.codeVerifier,
647
+ expectedState: pending.state,
648
+ expectedNonce: pending.nonce
649
+ }
650
+ );
554
651
  const claims = tokens.claims();
555
652
  if (!claims) throw new Error("Clearly.HUB sent no ID token");
556
653
  return {
@@ -563,7 +660,10 @@ var SignIn = class {
563
660
  };
564
661
  }
565
662
  async refresh(refreshToken) {
566
- const tokens = await oidc.refreshTokenGrant(await this.getConfiguration(), refreshToken);
663
+ const tokens = await oidc.refreshTokenGrant(
664
+ await this.getConfiguration(),
665
+ refreshToken
666
+ );
567
667
  return toSessionTokens(tokens, refreshToken);
568
668
  }
569
669
  // Signing out of Clearly.HUB too, or the next sign-in goes straight through
@@ -596,12 +696,16 @@ function parseCookies(header) {
596
696
  const cookies = /* @__PURE__ */ new Map();
597
697
  for (const part of header.split(";")) {
598
698
  const index = part.indexOf("=");
599
- if (index > 0) cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
699
+ if (index > 0)
700
+ cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
600
701
  }
601
702
  return cookies;
602
703
  }
603
704
  async function deriveKey(secret) {
604
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
705
+ const digest = await crypto.subtle.digest(
706
+ "SHA-256",
707
+ new TextEncoder().encode(secret)
708
+ );
605
709
  return new Uint8Array(digest);
606
710
  }
607
711
  var SessionStore = class {
@@ -619,8 +723,14 @@ var SessionStore = class {
619
723
  }
620
724
  if (!chunks.length) return { data: {}, chunkCount: 0 };
621
725
  try {
622
- const { plaintext } = await (0, import_jose.compactDecrypt)(chunks.join(""), await this.keyPromise);
623
- return { data: JSON.parse(new TextDecoder().decode(plaintext)), chunkCount: chunks.length };
726
+ const { plaintext } = await (0, import_jose.compactDecrypt)(
727
+ chunks.join(""),
728
+ await this.keyPromise
729
+ );
730
+ return {
731
+ data: JSON.parse(new TextDecoder().decode(plaintext)),
732
+ chunkCount: chunks.length
733
+ };
624
734
  } catch {
625
735
  return { data: {}, chunkCount: chunks.length };
626
736
  }
@@ -628,7 +738,9 @@ var SessionStore = class {
628
738
  // Set-Cookie headers for the new session, clearing chunks it no longer needs
629
739
  async write(data, previousChunkCount) {
630
740
  const isEmpty = Object.keys(data).length === 0;
631
- const chunks = isEmpty ? [] : (await this.encrypt(data)).match(new RegExp(`.{1,${CHUNK_SIZE}}`, "g"));
741
+ const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
742
+ new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
743
+ );
632
744
  const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
633
745
  const headers = chunks.map(
634
746
  (chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
@@ -675,10 +787,17 @@ function clearlyHub(options) {
675
787
  },
676
788
  getUserToken,
677
789
  // Fetch protected or paid data (for example from DATA_PICKER) with the
678
- // person's key. Open data works without it.
790
+ // person's key. Open data works without it. Only URLs on Clearly.HUB's
791
+ // data proxy: the key would let anyone else act as this person
679
792
  async fetchAsUser(request, url, init = {}) {
793
+ if (!isOnProxy(url, config.urls.proxy)) {
794
+ throw new Error(
795
+ `Clearly.HUB: fetchAsUser only sends the person's key to Clearly.HUB's data proxy (${config.urls.proxy}, set with urls.proxy), not to ${url}`
796
+ );
797
+ }
680
798
  const token = await getUserToken(request);
681
- if (!token) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
799
+ if (!token)
800
+ throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
682
801
  const headers = new Headers(init.headers);
683
802
  headers.set("Authorization", token);
684
803
  return config.fetch(url, { ...init, headers });
@@ -703,11 +822,18 @@ function clearlyHub(options) {
703
822
  }) {
704
823
  const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
705
824
  if (!person) {
706
- throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
825
+ throw new Error(
826
+ "Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
827
+ );
707
828
  }
708
- const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
829
+ const subscription = pickSubscription(
830
+ await parts.api.getSubscriptions(),
831
+ hubId
832
+ );
709
833
  if (subscription?.status !== "ACTIVE") {
710
- throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
834
+ throw new Error(
835
+ `Clearly.HUB: hub ${hubId} has no active subscription to your app`
836
+ );
711
837
  }
712
838
  await parts.api.reportUsage({
713
839
  sub: person,
@@ -718,6 +844,15 @@ function clearlyHub(options) {
718
844
  }
719
845
  };
720
846
  }
847
+ function isOnProxy(url, proxy) {
848
+ try {
849
+ const target = new URL(url);
850
+ const base = new URL(`${proxy}/`);
851
+ return target.origin === base.origin && target.pathname.startsWith(base.pathname);
852
+ } catch {
853
+ return false;
854
+ }
855
+ }
721
856
  // Annotate the CommonJS export names for ESM import in node:
722
857
  0 && (module.exports = {
723
858
  clearlyHub
package/dist/index.d.cts CHANGED
@@ -71,6 +71,26 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
71
71
  app?: unknown;
72
72
  };
73
73
 
74
+ declare const DEFAULT_BASE_PATH = "/clearly-hub";
75
+ declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
76
+ declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
77
+ declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
78
+ declare const ROUTES: {
79
+ readonly signIn: "/sign-in";
80
+ readonly callback: "/callback";
81
+ readonly signOut: "/sign-out";
82
+ readonly session: "/session";
83
+ readonly open: "/open";
84
+ readonly answer: "/answer";
85
+ readonly result: "/result";
86
+ readonly token: "/token";
87
+ };
88
+ declare const ANSWER_CHANNEL = "clearly-hub-answers";
89
+ interface AnswerMessage {
90
+ ref: string;
91
+ result: ScreenResult;
92
+ }
93
+
74
94
  interface SessionUser {
75
95
  sub: string;
76
96
  email: string;
@@ -89,24 +109,9 @@ type Session = {
89
109
  signedIn: true;
90
110
  user: SessionUser;
91
111
  };
92
-
93
- declare const DEFAULT_BASE_PATH = "/clearly-hub";
94
- declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
95
- declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
96
- declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
97
- declare const ROUTES: {
98
- readonly signIn: "/sign-in";
99
- readonly callback: "/callback";
100
- readonly signOut: "/sign-out";
101
- readonly session: "/session";
102
- readonly open: "/open";
103
- readonly answer: "/answer";
104
- readonly result: "/result";
105
- };
106
- declare const ANSWER_CHANNEL = "clearly-hub-answers";
107
- interface AnswerMessage {
108
- ref: string;
109
- result: ScreenResult;
112
+ interface AccessToken {
113
+ accessToken: string;
114
+ expiresIn: number;
110
115
  }
111
116
 
112
117
  interface SignedInPerson {
@@ -143,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
143
148
  interface ClearlyHubUrls {
144
149
  screens: string;
145
150
  api: string;
151
+ proxy: string;
146
152
  usage: string;
147
153
  publicKey: string;
148
154
  auth: string;
@@ -179,4 +185,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
179
185
  type ClearlyHub = ReturnType<typeof clearlyHub>;
180
186
 
181
187
  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 };
188
+ export type { AccessToken, 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
@@ -71,6 +71,26 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
71
71
  app?: unknown;
72
72
  };
73
73
 
74
+ declare const DEFAULT_BASE_PATH = "/clearly-hub";
75
+ declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
76
+ declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
77
+ declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
78
+ declare const ROUTES: {
79
+ readonly signIn: "/sign-in";
80
+ readonly callback: "/callback";
81
+ readonly signOut: "/sign-out";
82
+ readonly session: "/session";
83
+ readonly open: "/open";
84
+ readonly answer: "/answer";
85
+ readonly result: "/result";
86
+ readonly token: "/token";
87
+ };
88
+ declare const ANSWER_CHANNEL = "clearly-hub-answers";
89
+ interface AnswerMessage {
90
+ ref: string;
91
+ result: ScreenResult;
92
+ }
93
+
74
94
  interface SessionUser {
75
95
  sub: string;
76
96
  email: string;
@@ -89,24 +109,9 @@ type Session = {
89
109
  signedIn: true;
90
110
  user: SessionUser;
91
111
  };
92
-
93
- declare const DEFAULT_BASE_PATH = "/clearly-hub";
94
- declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
95
- declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
96
- declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
97
- declare const ROUTES: {
98
- readonly signIn: "/sign-in";
99
- readonly callback: "/callback";
100
- readonly signOut: "/sign-out";
101
- readonly session: "/session";
102
- readonly open: "/open";
103
- readonly answer: "/answer";
104
- readonly result: "/result";
105
- };
106
- declare const ANSWER_CHANNEL = "clearly-hub-answers";
107
- interface AnswerMessage {
108
- ref: string;
109
- result: ScreenResult;
112
+ interface AccessToken {
113
+ accessToken: string;
114
+ expiresIn: number;
110
115
  }
111
116
 
112
117
  interface SignedInPerson {
@@ -143,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
143
148
  interface ClearlyHubUrls {
144
149
  screens: string;
145
150
  api: string;
151
+ proxy: string;
146
152
  usage: string;
147
153
  publicKey: string;
148
154
  auth: string;
@@ -179,4 +185,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
179
185
  type ClearlyHub = ReturnType<typeof clearlyHub>;
180
186
 
181
187
  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 };
188
+ export type { AccessToken, 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
@@ -1,3 +1,20 @@
1
+ // ../shared/src/protocol.ts
2
+ var DEFAULT_BASE_PATH = "/clearly-hub";
3
+ var DEFAULT_HUB_URL = "https://hub.clearly.app";
4
+ var DEFAULT_AUTH_URL = "https://auth.clearly.app";
5
+ var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
6
+ var ROUTES = {
7
+ signIn: "/sign-in",
8
+ callback: "/callback",
9
+ signOut: "/sign-out",
10
+ session: "/session",
11
+ open: "/open",
12
+ answer: "/answer",
13
+ result: "/result",
14
+ token: "/token"
15
+ };
16
+ var ANSWER_CHANNEL = "clearly-hub-answers";
17
+
1
18
  // ../shared/src/screens.ts
2
19
  var SCREEN_ACTIONS = [
3
20
  "SELECT_SUBSCRIPTION",
@@ -26,22 +43,6 @@ function checkScreenData(action, data) {
26
43
  return void 0;
27
44
  }
28
45
 
29
- // ../shared/src/protocol.ts
30
- var DEFAULT_BASE_PATH = "/clearly-hub";
31
- var DEFAULT_HUB_URL = "https://hub.clearly.app";
32
- var DEFAULT_AUTH_URL = "https://auth.clearly.app";
33
- var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
34
- var ROUTES = {
35
- signIn: "/sign-in",
36
- callback: "/callback",
37
- signOut: "/sign-out",
38
- session: "/session",
39
- open: "/open",
40
- answer: "/answer",
41
- result: "/result"
42
- };
43
- var ANSWER_CHANNEL = "clearly-hub-answers";
44
-
45
46
  // src/config.ts
46
47
  var trimSlash = (url) => url.replace(/\/+$/, "");
47
48
  function resolveConfig(options) {
@@ -53,6 +54,7 @@ function resolveConfig(options) {
53
54
  const appUrl = trimSlash(options.appUrl);
54
55
  const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
55
56
  const api = trimSlash(options.urls?.api ?? hubUrl);
57
+ const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
56
58
  return {
57
59
  clientId: options.clientId,
58
60
  m2mClientId: options.m2mClientId,
@@ -64,7 +66,8 @@ function resolveConfig(options) {
64
66
  urls: {
65
67
  screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
66
68
  api,
67
- usage: options.urls?.usage ?? `${hubUrl}/register-usage`,
69
+ proxy,
70
+ usage: options.urls?.usage ?? `${proxy}/register-usage`,
68
71
  publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
69
72
  auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
70
73
  issuer: options.urls?.issuer ?? DEFAULT_ISSUER
@@ -84,11 +87,15 @@ function toNodeMiddleware(handle, appUrl) {
84
87
  try {
85
88
  const headers = new Headers();
86
89
  for (const [name, value] of Object.entries(req.headers)) {
87
- if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
90
+ if (Array.isArray(value))
91
+ value.forEach((item) => headers.append(name, item));
88
92
  else if (value !== void 0) headers.set(name, value);
89
93
  }
90
94
  const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
91
- const response = await handle(new Request(url, { method: req.method, headers }), req);
95
+ const response = await handle(
96
+ new Request(url, { method: req.method, headers }),
97
+ req
98
+ );
92
99
  if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
93
100
  next();
94
101
  return;
@@ -142,16 +149,24 @@ var Screens = class {
142
149
  base64Decode(signature),
143
150
  new TextEncoder().encode(data)
144
151
  );
145
- if (!isSigned) throw new AnswerError("This answer was not signed by Clearly.HUB");
146
- const { request, response } = JSON.parse(new TextDecoder().decode(base64Decode(data)));
147
- return { state: request.state, response };
152
+ if (!isSigned)
153
+ throw new AnswerError("This answer was not signed by Clearly.HUB");
154
+ const { request, response } = JSON.parse(
155
+ new TextDecoder().decode(base64Decode(data))
156
+ );
157
+ return { request: request ?? {}, response };
148
158
  }
149
159
  // Fetched once; a key change needs a restart
150
160
  getPublicKey() {
151
161
  this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
152
- if (!response.ok) throw new Error(`No public key at ${this.config.urls.publicKey}`);
162
+ if (!response.ok)
163
+ throw new Error(`No public key at ${this.config.urls.publicKey}`);
153
164
  return response.text();
154
- }).then((pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, ["verify"])).catch((error) => {
165
+ }).then(
166
+ (pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
167
+ "verify"
168
+ ])
169
+ ).catch((error) => {
155
170
  this.publicKey = void 0;
156
171
  throw error;
157
172
  });
@@ -162,7 +177,8 @@ var AnswerError = class extends Error {
162
177
  };
163
178
  function toScreenResult(action, response) {
164
179
  const { result, ...answer } = response;
165
- if (result === "SUCCESS") return { action, result: "SUCCESS", answer };
180
+ if (result === "SUCCESS")
181
+ return { action, result: "SUCCESS", answer };
166
182
  return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
167
183
  }
168
184
  function base64Encode(text) {
@@ -178,7 +194,11 @@ function readRawQuery(url) {
178
194
  const params = /* @__PURE__ */ new Map();
179
195
  for (const pair of url.search.slice(1).split("&")) {
180
196
  const index = pair.indexOf("=");
181
- if (index > 0) params.set(pair.slice(0, index), decodeURIComponent(pair.slice(index + 1)));
197
+ if (index > 0)
198
+ params.set(
199
+ pair.slice(0, index),
200
+ decodeURIComponent(pair.slice(index + 1))
201
+ );
182
202
  }
183
203
  return params;
184
204
  }
@@ -192,7 +212,8 @@ function createHandler(parts) {
192
212
  const url = new URL(request.url);
193
213
  if (!url.pathname.startsWith(config.basePath)) return notFound();
194
214
  const route = url.pathname.slice(config.basePath.length) || "/";
195
- if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
215
+ if (request.method !== "GET")
216
+ return new Response("Method not allowed", { status: 405 });
196
217
  const host = request.headers.get("host");
197
218
  const appHost = new URL(config.appUrl).host;
198
219
  if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
@@ -205,8 +226,12 @@ function createHandler(parts) {
205
226
  response = await routeRequest(route, url, data, parts, { request, req });
206
227
  } catch (error) {
207
228
  const isRefusedAnswer = error instanceof AnswerError;
208
- if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
209
- response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
229
+ if (!isRefusedAnswer)
230
+ console.error(`Clearly.HUB: ${route} failed:`, error);
231
+ response = isRefusedAnswer ? errorPage(400, error.message) : errorPage(
232
+ 500,
233
+ config.isSecure ? "Something went wrong with Clearly.HUB. Please try again." : describeError(error)
234
+ );
210
235
  }
211
236
  if (JSON.stringify(data) !== before) {
212
237
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -223,7 +248,8 @@ async function routeRequest(route, url, session, parts, incoming) {
223
248
  case ROUTES.callback:
224
249
  return finishSignIn(url, session, parts, incoming);
225
250
  case ROUTES.signOut:
226
- for (const key of Object.keys(session)) delete session[key];
251
+ for (const key of Object.keys(session))
252
+ delete session[key];
227
253
  return redirect(parts.signIn.signOutUrl());
228
254
  case ROUTES.session:
229
255
  return json(await readSession(session, parts));
@@ -236,12 +262,16 @@ async function routeRequest(route, url, session, parts, incoming) {
236
262
  delete session.result;
237
263
  return json(result);
238
264
  }
265
+ case ROUTES.token:
266
+ return readToken(session, parts);
239
267
  default:
240
268
  return notFound();
241
269
  }
242
270
  }
243
271
  async function startSignIn(url, session, { config, signIn }) {
244
- const { url: authorizationUrl, pending } = await signIn.start(safeReturnTo(url, config));
272
+ const { url: authorizationUrl, pending } = await signIn.start(
273
+ safeReturnTo(url, config)
274
+ );
245
275
  session.signIn = pending;
246
276
  return redirect(authorizationUrl);
247
277
  }
@@ -255,13 +285,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
255
285
  const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
256
286
  session.user = { ...person, appUserId };
257
287
  session.tokens = tokens;
258
- return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
288
+ return redirect(
289
+ `${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
290
+ );
259
291
  }
260
292
  async function readSession(session, parts) {
261
293
  const accessToken = await getFreshAccessToken(session, parts);
262
294
  if (!session.user || !accessToken) return { signedIn: false };
263
295
  return { signedIn: true, user: session.user };
264
296
  }
297
+ async function readToken(session, parts) {
298
+ const accessToken = await getFreshAccessToken(session, parts);
299
+ if (!session.user || !accessToken)
300
+ return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
301
+ const token = {
302
+ accessToken,
303
+ expiresIn: Math.max(
304
+ 0,
305
+ session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
306
+ )
307
+ };
308
+ return json(token);
309
+ }
265
310
  async function getFreshAccessToken(session, { signIn }) {
266
311
  const tokens = session.tokens;
267
312
  if (!tokens) return void 0;
@@ -278,7 +323,8 @@ async function getFreshAccessToken(session, { signIn }) {
278
323
  }
279
324
  function openScreen(url, session, { config, screens }) {
280
325
  const action = url.searchParams.get("action");
281
- if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
326
+ if (!isScreenAction(action))
327
+ return errorPage(400, `Unknown screen: ${action}`);
282
328
  const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
283
329
  const dataParam = url.searchParams.get("data");
284
330
  let data;
@@ -303,15 +349,21 @@ function openScreen(url, session, { config, screens }) {
303
349
  }
304
350
  async function receiveAnswer(url, session, { config, screens }, incoming) {
305
351
  const query = readRawQuery(url);
306
- const { state, response } = await screens.readAnswer(
352
+ const { request, response } = await screens.readAnswer(
307
353
  query.get("data") ?? "",
308
354
  query.get("signature") ?? ""
309
355
  );
356
+ const state = request.state;
310
357
  const pending = state ? session.screens?.[state] : void 0;
311
358
  if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
312
- throw new AnswerError("This answer does not belong to a screen opened in this browser");
359
+ throw new AnswerError(
360
+ "This answer does not belong to a screen opened in this browser"
361
+ );
313
362
  }
314
363
  delete session.screens[state];
364
+ if (request.client_id !== config.clientId || request.redirect_url !== screens.answerUrl || request.actions?.length !== 1 || request.actions[0] !== pending.action) {
365
+ throw new AnswerError("This answer was given to another app or screen");
366
+ }
315
367
  let result = toScreenResult(pending.action, response);
316
368
  try {
317
369
  const app = await config.onAnswer?.(result, {
@@ -321,7 +373,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
321
373
  });
322
374
  if (app !== void 0) result = { ...result, app };
323
375
  } catch (error) {
324
- result = { action: pending.action, result: "FAILURE", error: error.message };
376
+ result = {
377
+ action: pending.action,
378
+ result: "FAILURE",
379
+ error: error.message
380
+ };
325
381
  }
326
382
  if (pending.display === "redirect") {
327
383
  session.result = { ref: pending.ref, result };
@@ -356,11 +412,21 @@ function safePath(path, config) {
356
412
  }
357
413
  }
358
414
  var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
359
- var json = (body) => new Response(JSON.stringify(body), {
360
- headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
415
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
416
+ status,
417
+ headers: {
418
+ "Content-Type": "application/json",
419
+ "Cache-Control": "no-store"
420
+ }
421
+ });
422
+ var html = (body, status = 200) => new Response(body, {
423
+ status,
424
+ headers: { "Content-Type": "text/html; charset=utf-8" }
425
+ });
426
+ var notFound = () => new Response("Not found", {
427
+ status: 404,
428
+ headers: { "X-Clearly-Hub": "unhandled" }
361
429
  });
362
- var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
363
- var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
364
430
  function describeError(error) {
365
431
  const messages = [];
366
432
  for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
@@ -369,7 +435,10 @@ function describeError(error) {
369
435
  return messages.join(": ") || String(error);
370
436
  }
371
437
  function errorPage(status, message) {
372
- const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
438
+ const escaped = message.replace(
439
+ /[&<>"]/g,
440
+ (char) => `&#${char.charCodeAt(0)};`
441
+ );
373
442
  return html(
374
443
  `<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
375
444
  status
@@ -391,7 +460,9 @@ var HubApi = class {
391
460
  try {
392
461
  return await this.config.fetch(url, init);
393
462
  } catch (error) {
394
- throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
463
+ throw new Error(`Clearly.HUB could not be reached at ${url}`, {
464
+ cause: error
465
+ });
395
466
  }
396
467
  }
397
468
  get hasAppKey() {
@@ -401,11 +472,16 @@ var HubApi = class {
401
472
  // until shortly before it expires
402
473
  async getAppToken() {
403
474
  const now = Date.now() / 1e3;
404
- if (this.appToken && this.appToken.expiresAt > now + 60) return this.appToken.value;
475
+ if (this.appToken && this.appToken.expiresAt > now + 60)
476
+ return this.appToken.value;
405
477
  if (!this.hasAppKey) {
406
- throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
478
+ throw new Error(
479
+ "Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
480
+ );
407
481
  }
408
- const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
482
+ const credentials = btoa(
483
+ `${this.config.m2mClientId}:${this.config.m2mClientSecret}`
484
+ );
409
485
  const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
410
486
  method: "POST",
411
487
  headers: {
@@ -421,7 +497,10 @@ var HubApi = class {
421
497
  throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
422
498
  }
423
499
  const body = await response.json();
424
- this.appToken = { value: body.access_token, expiresAt: now + body.expires_in };
500
+ this.appToken = {
501
+ value: body.access_token,
502
+ expiresAt: now + body.expires_in
503
+ };
425
504
  return body.access_token;
426
505
  }
427
506
  // Every hub that bought your app, with status and your plan id
@@ -429,11 +508,16 @@ var HubApi = class {
429
508
  if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
430
509
  return this.subscriptions.value;
431
510
  }
432
- const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
433
- headers: { Authorization: `Bearer ${await this.getAppToken()}` }
434
- });
511
+ const response = await this.call(
512
+ `${this.config.urls.api}/api/app-api/subscriptions`,
513
+ {
514
+ headers: { Authorization: `Bearer ${await this.getAppToken()}` }
515
+ }
516
+ );
435
517
  if (!response.ok) {
436
- throw new Error(`Could not read the subscriptions from Clearly.HUB (${response.status})`);
518
+ throw new Error(
519
+ `Could not read the subscriptions from Clearly.HUB (${response.status})`
520
+ );
437
521
  }
438
522
  const items = await response.json();
439
523
  const value = items.map((item) => ({
@@ -466,12 +550,16 @@ var HubApi = class {
466
550
  });
467
551
  if (!response.ok) {
468
552
  const body = await response.json().catch(() => ({}));
469
- throw new Error(`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`);
553
+ throw new Error(
554
+ `Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
555
+ );
470
556
  }
471
557
  }
472
558
  };
473
559
  function pickSubscription(subscriptions, hubId) {
474
- const ofHub = subscriptions.filter((subscription) => subscription.hubId === hubId);
560
+ const ofHub = subscriptions.filter(
561
+ (subscription) => subscription.hubId === hubId
562
+ );
475
563
  return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
476
564
  }
477
565
 
@@ -488,7 +576,12 @@ var SignIn = class {
488
576
  }
489
577
  // Discovery happens once, on the first sign-in
490
578
  getConfiguration() {
491
- this.configuration ??= oidc.discovery(new URL(this.config.urls.issuer), this.config.clientId, void 0, oidc.None()).catch((error) => {
579
+ this.configuration ??= oidc.discovery(
580
+ new URL(this.config.urls.issuer),
581
+ this.config.clientId,
582
+ void 0,
583
+ oidc.None()
584
+ ).catch((error) => {
492
585
  this.configuration = void 0;
493
586
  throw error;
494
587
  });
@@ -510,11 +603,15 @@ var SignIn = class {
510
603
  return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
511
604
  }
512
605
  async finish(currentUrl, pending) {
513
- const tokens = await oidc.authorizationCodeGrant(await this.getConfiguration(), currentUrl, {
514
- pkceCodeVerifier: pending.codeVerifier,
515
- expectedState: pending.state,
516
- expectedNonce: pending.nonce
517
- });
606
+ const tokens = await oidc.authorizationCodeGrant(
607
+ await this.getConfiguration(),
608
+ currentUrl,
609
+ {
610
+ pkceCodeVerifier: pending.codeVerifier,
611
+ expectedState: pending.state,
612
+ expectedNonce: pending.nonce
613
+ }
614
+ );
518
615
  const claims = tokens.claims();
519
616
  if (!claims) throw new Error("Clearly.HUB sent no ID token");
520
617
  return {
@@ -527,7 +624,10 @@ var SignIn = class {
527
624
  };
528
625
  }
529
626
  async refresh(refreshToken) {
530
- const tokens = await oidc.refreshTokenGrant(await this.getConfiguration(), refreshToken);
627
+ const tokens = await oidc.refreshTokenGrant(
628
+ await this.getConfiguration(),
629
+ refreshToken
630
+ );
531
631
  return toSessionTokens(tokens, refreshToken);
532
632
  }
533
633
  // Signing out of Clearly.HUB too, or the next sign-in goes straight through
@@ -560,12 +660,16 @@ function parseCookies(header) {
560
660
  const cookies = /* @__PURE__ */ new Map();
561
661
  for (const part of header.split(";")) {
562
662
  const index = part.indexOf("=");
563
- if (index > 0) cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
663
+ if (index > 0)
664
+ cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
564
665
  }
565
666
  return cookies;
566
667
  }
567
668
  async function deriveKey(secret) {
568
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
669
+ const digest = await crypto.subtle.digest(
670
+ "SHA-256",
671
+ new TextEncoder().encode(secret)
672
+ );
569
673
  return new Uint8Array(digest);
570
674
  }
571
675
  var SessionStore = class {
@@ -583,8 +687,14 @@ var SessionStore = class {
583
687
  }
584
688
  if (!chunks.length) return { data: {}, chunkCount: 0 };
585
689
  try {
586
- const { plaintext } = await compactDecrypt(chunks.join(""), await this.keyPromise);
587
- return { data: JSON.parse(new TextDecoder().decode(plaintext)), chunkCount: chunks.length };
690
+ const { plaintext } = await compactDecrypt(
691
+ chunks.join(""),
692
+ await this.keyPromise
693
+ );
694
+ return {
695
+ data: JSON.parse(new TextDecoder().decode(plaintext)),
696
+ chunkCount: chunks.length
697
+ };
588
698
  } catch {
589
699
  return { data: {}, chunkCount: chunks.length };
590
700
  }
@@ -592,7 +702,9 @@ var SessionStore = class {
592
702
  // Set-Cookie headers for the new session, clearing chunks it no longer needs
593
703
  async write(data, previousChunkCount) {
594
704
  const isEmpty = Object.keys(data).length === 0;
595
- const chunks = isEmpty ? [] : (await this.encrypt(data)).match(new RegExp(`.{1,${CHUNK_SIZE}}`, "g"));
705
+ const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
706
+ new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
707
+ );
596
708
  const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
597
709
  const headers = chunks.map(
598
710
  (chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
@@ -639,10 +751,17 @@ function clearlyHub(options) {
639
751
  },
640
752
  getUserToken,
641
753
  // Fetch protected or paid data (for example from DATA_PICKER) with the
642
- // person's key. Open data works without it.
754
+ // person's key. Open data works without it. Only URLs on Clearly.HUB's
755
+ // data proxy: the key would let anyone else act as this person
643
756
  async fetchAsUser(request, url, init = {}) {
757
+ if (!isOnProxy(url, config.urls.proxy)) {
758
+ throw new Error(
759
+ `Clearly.HUB: fetchAsUser only sends the person's key to Clearly.HUB's data proxy (${config.urls.proxy}, set with urls.proxy), not to ${url}`
760
+ );
761
+ }
644
762
  const token = await getUserToken(request);
645
- if (!token) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
763
+ if (!token)
764
+ throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
646
765
  const headers = new Headers(init.headers);
647
766
  headers.set("Authorization", token);
648
767
  return config.fetch(url, { ...init, headers });
@@ -667,11 +786,18 @@ function clearlyHub(options) {
667
786
  }) {
668
787
  const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
669
788
  if (!person) {
670
- throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
789
+ throw new Error(
790
+ "Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
791
+ );
671
792
  }
672
- const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
793
+ const subscription = pickSubscription(
794
+ await parts.api.getSubscriptions(),
795
+ hubId
796
+ );
673
797
  if (subscription?.status !== "ACTIVE") {
674
- throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
798
+ throw new Error(
799
+ `Clearly.HUB: hub ${hubId} has no active subscription to your app`
800
+ );
675
801
  }
676
802
  await parts.api.reportUsage({
677
803
  sub: person,
@@ -682,6 +808,15 @@ function clearlyHub(options) {
682
808
  }
683
809
  };
684
810
  }
811
+ function isOnProxy(url, proxy) {
812
+ try {
813
+ const target = new URL(url);
814
+ const base = new URL(`${proxy}/`);
815
+ return target.origin === base.origin && target.pathname.startsWith(base.pathname);
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
685
820
  export {
686
821
  clearlyHub
687
822
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearly-hub/node",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",