@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 +16 -7
- package/dist/index.cjs +29 -5
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +29 -5
- package/package.json +1 -1
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:
|
|
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,
|
|
147
|
-
opened in this browser, once (`state`)
|
|
148
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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
package/dist/index.d.ts
CHANGED
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
|
-
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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.
|
|
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",
|