@clearly-hub/node 0.4.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
@@ -31,7 +31,7 @@ const hub = clearlyHub({
31
31
  m2mClientId: process.env.CLEARLY_HUB_M2M_CLIENT_ID,
32
32
  m2mClientSecret: process.env.CLEARLY_HUB_M2M_CLIENT_SECRET,
33
33
  appUrl: 'https://app.example.com',
34
- secret: process.env.SESSION_SECRET, // 32+ characters
34
+ secret: process.env.SESSION_SECRET, // 32+ random characters
35
35
  });
36
36
 
37
37
  const app = express();
@@ -114,7 +114,8 @@ or plugin that adds headers to each request. The key lasts an hour; `getToken()`
114
114
  time, so call it again rather than keeping the value. On your server, `hub.fetchAsUser(req, url)`
115
115
  does the same.
116
116
 
117
- The key is the person's own Clearly.HUB key: keep scripts you don't trust off pages that use it.
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.
118
119
 
119
120
  ## The Node package
120
121
 
@@ -124,7 +125,7 @@ The key is the person's own Clearly.HUB key: keep scripts you don't trust off pa
124
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 |
125
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` |
126
127
  | `hub.getUser(req)` | Who's signed in, in your own routes |
127
- | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) on your server, 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 |
128
129
  | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
129
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 |
130
131
  | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
@@ -143,11 +144,19 @@ const hub = clearlyHub<express.Request>({
143
144
  });
144
145
  ```
145
146
 
146
- **What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
147
- opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
148
- 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).
149
150
 
150
- **Local Clearly.HUB:** pass `urls: { screens, api, usage, auth, issuer }`; see
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.
158
+
159
+ **Local Clearly.HUB:** pass `urls: { screens, api, proxy, auth, issuer }`; see
151
160
  `examples/express/.env.example`.
152
161
 
153
162
  ## Not covered (yet)
package/dist/index.cjs CHANGED
@@ -90,6 +90,7 @@ function resolveConfig(options) {
90
90
  const appUrl = trimSlash(options.appUrl);
91
91
  const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
92
92
  const api = trimSlash(options.urls?.api ?? hubUrl);
93
+ const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
93
94
  return {
94
95
  clientId: options.clientId,
95
96
  m2mClientId: options.m2mClientId,
@@ -101,7 +102,8 @@ function resolveConfig(options) {
101
102
  urls: {
102
103
  screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
103
104
  api,
104
- usage: options.urls?.usage ?? `${hubUrl}/register-usage`,
105
+ proxy,
106
+ usage: options.urls?.usage ?? `${proxy}/register-usage`,
105
107
  publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
106
108
  auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
107
109
  issuer: options.urls?.issuer ?? DEFAULT_ISSUER
@@ -188,7 +190,7 @@ var Screens = class {
188
190
  const { request, response } = JSON.parse(
189
191
  new TextDecoder().decode(base64Decode(data))
190
192
  );
191
- return { state: request.state, response };
193
+ return { request: request ?? {}, response };
192
194
  }
193
195
  // Fetched once; a key change needs a restart
194
196
  getPublicKey() {
@@ -262,7 +264,10 @@ function createHandler(parts) {
262
264
  const isRefusedAnswer = error instanceof AnswerError;
263
265
  if (!isRefusedAnswer)
264
266
  console.error(`Clearly.HUB: ${route} failed:`, error);
265
- response = errorPage(isRefusedAnswer ? 400 : 500, describeError(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
+ );
266
271
  }
267
272
  if (JSON.stringify(data) !== before) {
268
273
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -380,10 +385,11 @@ function openScreen(url, session, { config, screens }) {
380
385
  }
381
386
  async function receiveAnswer(url, session, { config, screens }, incoming) {
382
387
  const query = readRawQuery(url);
383
- const { state, response } = await screens.readAnswer(
388
+ const { request, response } = await screens.readAnswer(
384
389
  query.get("data") ?? "",
385
390
  query.get("signature") ?? ""
386
391
  );
392
+ const state = request.state;
387
393
  const pending = state ? session.screens?.[state] : void 0;
388
394
  if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
389
395
  throw new AnswerError(
@@ -391,6 +397,9 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
391
397
  );
392
398
  }
393
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
+ }
394
403
  let result = toScreenResult(pending.action, response);
395
404
  try {
396
405
  const app = await config.onAnswer?.(result, {
@@ -778,8 +787,14 @@ function clearlyHub(options) {
778
787
  },
779
788
  getUserToken,
780
789
  // Fetch protected or paid data (for example from DATA_PICKER) with the
781
- // 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
782
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
+ }
783
798
  const token = await getUserToken(request);
784
799
  if (!token)
785
800
  throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
@@ -829,6 +844,15 @@ function clearlyHub(options) {
829
844
  }
830
845
  };
831
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
+ }
832
856
  // Annotate the CommonJS export names for ESM import in node:
833
857
  0 && (module.exports = {
834
858
  clearlyHub
package/dist/index.d.cts CHANGED
@@ -148,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
148
148
  interface ClearlyHubUrls {
149
149
  screens: string;
150
150
  api: string;
151
+ proxy: string;
151
152
  usage: string;
152
153
  publicKey: string;
153
154
  auth: string;
package/dist/index.d.ts CHANGED
@@ -148,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
148
148
  interface ClearlyHubUrls {
149
149
  screens: string;
150
150
  api: string;
151
+ proxy: string;
151
152
  usage: string;
152
153
  publicKey: string;
153
154
  auth: string;
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ function resolveConfig(options) {
54
54
  const appUrl = trimSlash(options.appUrl);
55
55
  const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
56
56
  const api = trimSlash(options.urls?.api ?? hubUrl);
57
+ const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
57
58
  return {
58
59
  clientId: options.clientId,
59
60
  m2mClientId: options.m2mClientId,
@@ -65,7 +66,8 @@ function resolveConfig(options) {
65
66
  urls: {
66
67
  screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
67
68
  api,
68
- usage: options.urls?.usage ?? `${hubUrl}/register-usage`,
69
+ proxy,
70
+ usage: options.urls?.usage ?? `${proxy}/register-usage`,
69
71
  publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
70
72
  auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
71
73
  issuer: options.urls?.issuer ?? DEFAULT_ISSUER
@@ -152,7 +154,7 @@ var Screens = class {
152
154
  const { request, response } = JSON.parse(
153
155
  new TextDecoder().decode(base64Decode(data))
154
156
  );
155
- return { state: request.state, response };
157
+ return { request: request ?? {}, response };
156
158
  }
157
159
  // Fetched once; a key change needs a restart
158
160
  getPublicKey() {
@@ -226,7 +228,10 @@ function createHandler(parts) {
226
228
  const isRefusedAnswer = error instanceof AnswerError;
227
229
  if (!isRefusedAnswer)
228
230
  console.error(`Clearly.HUB: ${route} failed:`, error);
229
- response = errorPage(isRefusedAnswer ? 400 : 500, describeError(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
+ );
230
235
  }
231
236
  if (JSON.stringify(data) !== before) {
232
237
  for (const cookie of await sessions.write(data, chunkCount)) {
@@ -344,10 +349,11 @@ function openScreen(url, session, { config, screens }) {
344
349
  }
345
350
  async function receiveAnswer(url, session, { config, screens }, incoming) {
346
351
  const query = readRawQuery(url);
347
- const { state, response } = await screens.readAnswer(
352
+ const { request, response } = await screens.readAnswer(
348
353
  query.get("data") ?? "",
349
354
  query.get("signature") ?? ""
350
355
  );
356
+ const state = request.state;
351
357
  const pending = state ? session.screens?.[state] : void 0;
352
358
  if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
353
359
  throw new AnswerError(
@@ -355,6 +361,9 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
355
361
  );
356
362
  }
357
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
+ }
358
367
  let result = toScreenResult(pending.action, response);
359
368
  try {
360
369
  const app = await config.onAnswer?.(result, {
@@ -742,8 +751,14 @@ function clearlyHub(options) {
742
751
  },
743
752
  getUserToken,
744
753
  // Fetch protected or paid data (for example from DATA_PICKER) with the
745
- // 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
746
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
+ }
747
762
  const token = await getUserToken(request);
748
763
  if (!token)
749
764
  throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
@@ -793,6 +808,15 @@ function clearlyHub(options) {
793
808
  }
794
809
  };
795
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
+ }
796
820
  export {
797
821
  clearlyHub
798
822
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearly-hub/node",
3
- "version": "0.4.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",