@clearly-hub/node 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/dist/index.cjs +175 -64
- package/dist/index.d.cts +24 -19
- package/dist/index.d.ts +24 -19
- package/dist/index.js +175 -64
- package/package.json +1 -1
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.
|
|
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).
|
|
@@ -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) =>
|
|
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,33 @@ 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: keep scripts you don't trust off pages that use it.
|
|
118
|
+
|
|
97
119
|
## The Node package
|
|
98
120
|
|
|
99
121
|
| | |
|
|
100
122
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
-
| `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result`
|
|
123
|
+
| `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result`, `/token` |
|
|
102
124
|
| `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
125
|
| `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
126
|
| `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
|
|
127
|
+
| `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) on your server, with the person's key |
|
|
106
128
|
| `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
|
|
107
129
|
| `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
130
|
| `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
|
|
@@ -132,7 +154,10 @@ store; it's split over several cookies because tokens are large.
|
|
|
132
154
|
|
|
133
155
|
- Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
|
|
134
156
|
`onAnswer` for `SELECT_SUBSCRIPTION`.
|
|
135
|
-
- Other server languages:
|
|
157
|
+
- Other server languages (.NET, Python, ...): the browser package works with any
|
|
158
|
+
backend that answers the same routes. Contact us at
|
|
159
|
+
[support@futureinsight.nl](mailto:support@futureinsight.nl) and we'll help
|
|
160
|
+
you with yours.
|
|
136
161
|
|
|
137
162
|
## Working on this repo
|
|
138
163
|
|
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) {
|
|
@@ -120,11 +121,15 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
120
121
|
try {
|
|
121
122
|
const headers = new Headers();
|
|
122
123
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
123
|
-
if (Array.isArray(value))
|
|
124
|
+
if (Array.isArray(value))
|
|
125
|
+
value.forEach((item) => headers.append(name, item));
|
|
124
126
|
else if (value !== void 0) headers.set(name, value);
|
|
125
127
|
}
|
|
126
128
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
127
|
-
const response = await handle(
|
|
129
|
+
const response = await handle(
|
|
130
|
+
new Request(url, { method: req.method, headers }),
|
|
131
|
+
req
|
|
132
|
+
);
|
|
128
133
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
129
134
|
next();
|
|
130
135
|
return;
|
|
@@ -178,16 +183,24 @@ var Screens = class {
|
|
|
178
183
|
base64Decode(signature),
|
|
179
184
|
new TextEncoder().encode(data)
|
|
180
185
|
);
|
|
181
|
-
if (!isSigned)
|
|
182
|
-
|
|
186
|
+
if (!isSigned)
|
|
187
|
+
throw new AnswerError("This answer was not signed by Clearly.HUB");
|
|
188
|
+
const { request, response } = JSON.parse(
|
|
189
|
+
new TextDecoder().decode(base64Decode(data))
|
|
190
|
+
);
|
|
183
191
|
return { state: request.state, response };
|
|
184
192
|
}
|
|
185
193
|
// Fetched once; a key change needs a restart
|
|
186
194
|
getPublicKey() {
|
|
187
195
|
this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
|
|
188
|
-
if (!response.ok)
|
|
196
|
+
if (!response.ok)
|
|
197
|
+
throw new Error(`No public key at ${this.config.urls.publicKey}`);
|
|
189
198
|
return response.text();
|
|
190
|
-
}).then(
|
|
199
|
+
}).then(
|
|
200
|
+
(pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
|
|
201
|
+
"verify"
|
|
202
|
+
])
|
|
203
|
+
).catch((error) => {
|
|
191
204
|
this.publicKey = void 0;
|
|
192
205
|
throw error;
|
|
193
206
|
});
|
|
@@ -198,7 +211,8 @@ var AnswerError = class extends Error {
|
|
|
198
211
|
};
|
|
199
212
|
function toScreenResult(action, response) {
|
|
200
213
|
const { result, ...answer } = response;
|
|
201
|
-
if (result === "SUCCESS")
|
|
214
|
+
if (result === "SUCCESS")
|
|
215
|
+
return { action, result: "SUCCESS", answer };
|
|
202
216
|
return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
|
|
203
217
|
}
|
|
204
218
|
function base64Encode(text) {
|
|
@@ -214,7 +228,11 @@ function readRawQuery(url) {
|
|
|
214
228
|
const params = /* @__PURE__ */ new Map();
|
|
215
229
|
for (const pair of url.search.slice(1).split("&")) {
|
|
216
230
|
const index = pair.indexOf("=");
|
|
217
|
-
if (index > 0)
|
|
231
|
+
if (index > 0)
|
|
232
|
+
params.set(
|
|
233
|
+
pair.slice(0, index),
|
|
234
|
+
decodeURIComponent(pair.slice(index + 1))
|
|
235
|
+
);
|
|
218
236
|
}
|
|
219
237
|
return params;
|
|
220
238
|
}
|
|
@@ -228,7 +246,8 @@ function createHandler(parts) {
|
|
|
228
246
|
const url = new URL(request.url);
|
|
229
247
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
230
248
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
231
|
-
if (request.method !== "GET")
|
|
249
|
+
if (request.method !== "GET")
|
|
250
|
+
return new Response("Method not allowed", { status: 405 });
|
|
232
251
|
const host = request.headers.get("host");
|
|
233
252
|
const appHost = new URL(config.appUrl).host;
|
|
234
253
|
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
@@ -241,7 +260,8 @@ function createHandler(parts) {
|
|
|
241
260
|
response = await routeRequest(route, url, data, parts, { request, req });
|
|
242
261
|
} catch (error) {
|
|
243
262
|
const isRefusedAnswer = error instanceof AnswerError;
|
|
244
|
-
if (!isRefusedAnswer)
|
|
263
|
+
if (!isRefusedAnswer)
|
|
264
|
+
console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
245
265
|
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
246
266
|
}
|
|
247
267
|
if (JSON.stringify(data) !== before) {
|
|
@@ -259,7 +279,8 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
259
279
|
case ROUTES.callback:
|
|
260
280
|
return finishSignIn(url, session, parts, incoming);
|
|
261
281
|
case ROUTES.signOut:
|
|
262
|
-
for (const key of Object.keys(session))
|
|
282
|
+
for (const key of Object.keys(session))
|
|
283
|
+
delete session[key];
|
|
263
284
|
return redirect(parts.signIn.signOutUrl());
|
|
264
285
|
case ROUTES.session:
|
|
265
286
|
return json(await readSession(session, parts));
|
|
@@ -272,12 +293,16 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
272
293
|
delete session.result;
|
|
273
294
|
return json(result);
|
|
274
295
|
}
|
|
296
|
+
case ROUTES.token:
|
|
297
|
+
return readToken(session, parts);
|
|
275
298
|
default:
|
|
276
299
|
return notFound();
|
|
277
300
|
}
|
|
278
301
|
}
|
|
279
302
|
async function startSignIn(url, session, { config, signIn }) {
|
|
280
|
-
const { url: authorizationUrl, pending } = await signIn.start(
|
|
303
|
+
const { url: authorizationUrl, pending } = await signIn.start(
|
|
304
|
+
safeReturnTo(url, config)
|
|
305
|
+
);
|
|
281
306
|
session.signIn = pending;
|
|
282
307
|
return redirect(authorizationUrl);
|
|
283
308
|
}
|
|
@@ -291,13 +316,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
|
291
316
|
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
292
317
|
session.user = { ...person, appUserId };
|
|
293
318
|
session.tokens = tokens;
|
|
294
|
-
return redirect(
|
|
319
|
+
return redirect(
|
|
320
|
+
`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
|
|
321
|
+
);
|
|
295
322
|
}
|
|
296
323
|
async function readSession(session, parts) {
|
|
297
324
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
298
325
|
if (!session.user || !accessToken) return { signedIn: false };
|
|
299
326
|
return { signedIn: true, user: session.user };
|
|
300
327
|
}
|
|
328
|
+
async function readToken(session, parts) {
|
|
329
|
+
const accessToken = await getFreshAccessToken(session, parts);
|
|
330
|
+
if (!session.user || !accessToken)
|
|
331
|
+
return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
|
|
332
|
+
const token = {
|
|
333
|
+
accessToken,
|
|
334
|
+
expiresIn: Math.max(
|
|
335
|
+
0,
|
|
336
|
+
session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
|
|
337
|
+
)
|
|
338
|
+
};
|
|
339
|
+
return json(token);
|
|
340
|
+
}
|
|
301
341
|
async function getFreshAccessToken(session, { signIn }) {
|
|
302
342
|
const tokens = session.tokens;
|
|
303
343
|
if (!tokens) return void 0;
|
|
@@ -314,7 +354,8 @@ async function getFreshAccessToken(session, { signIn }) {
|
|
|
314
354
|
}
|
|
315
355
|
function openScreen(url, session, { config, screens }) {
|
|
316
356
|
const action = url.searchParams.get("action");
|
|
317
|
-
if (!isScreenAction(action))
|
|
357
|
+
if (!isScreenAction(action))
|
|
358
|
+
return errorPage(400, `Unknown screen: ${action}`);
|
|
318
359
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
319
360
|
const dataParam = url.searchParams.get("data");
|
|
320
361
|
let data;
|
|
@@ -345,7 +386,9 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
345
386
|
);
|
|
346
387
|
const pending = state ? session.screens?.[state] : void 0;
|
|
347
388
|
if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
|
|
348
|
-
throw new AnswerError(
|
|
389
|
+
throw new AnswerError(
|
|
390
|
+
"This answer does not belong to a screen opened in this browser"
|
|
391
|
+
);
|
|
349
392
|
}
|
|
350
393
|
delete session.screens[state];
|
|
351
394
|
let result = toScreenResult(pending.action, response);
|
|
@@ -357,7 +400,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
357
400
|
});
|
|
358
401
|
if (app !== void 0) result = { ...result, app };
|
|
359
402
|
} catch (error) {
|
|
360
|
-
result = {
|
|
403
|
+
result = {
|
|
404
|
+
action: pending.action,
|
|
405
|
+
result: "FAILURE",
|
|
406
|
+
error: error.message
|
|
407
|
+
};
|
|
361
408
|
}
|
|
362
409
|
if (pending.display === "redirect") {
|
|
363
410
|
session.result = { ref: pending.ref, result };
|
|
@@ -392,11 +439,21 @@ function safePath(path, config) {
|
|
|
392
439
|
}
|
|
393
440
|
}
|
|
394
441
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
395
|
-
var json = (body) => new Response(JSON.stringify(body), {
|
|
396
|
-
|
|
442
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
443
|
+
status,
|
|
444
|
+
headers: {
|
|
445
|
+
"Content-Type": "application/json",
|
|
446
|
+
"Cache-Control": "no-store"
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
var html = (body, status = 200) => new Response(body, {
|
|
450
|
+
status,
|
|
451
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
452
|
+
});
|
|
453
|
+
var notFound = () => new Response("Not found", {
|
|
454
|
+
status: 404,
|
|
455
|
+
headers: { "X-Clearly-Hub": "unhandled" }
|
|
397
456
|
});
|
|
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
457
|
function describeError(error) {
|
|
401
458
|
const messages = [];
|
|
402
459
|
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
@@ -405,7 +462,10 @@ function describeError(error) {
|
|
|
405
462
|
return messages.join(": ") || String(error);
|
|
406
463
|
}
|
|
407
464
|
function errorPage(status, message) {
|
|
408
|
-
const escaped = message.replace(
|
|
465
|
+
const escaped = message.replace(
|
|
466
|
+
/[&<>"]/g,
|
|
467
|
+
(char) => `&#${char.charCodeAt(0)};`
|
|
468
|
+
);
|
|
409
469
|
return html(
|
|
410
470
|
`<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
|
|
411
471
|
status
|
|
@@ -427,7 +487,9 @@ var HubApi = class {
|
|
|
427
487
|
try {
|
|
428
488
|
return await this.config.fetch(url, init);
|
|
429
489
|
} catch (error) {
|
|
430
|
-
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
490
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
491
|
+
cause: error
|
|
492
|
+
});
|
|
431
493
|
}
|
|
432
494
|
}
|
|
433
495
|
get hasAppKey() {
|
|
@@ -437,11 +499,16 @@ var HubApi = class {
|
|
|
437
499
|
// until shortly before it expires
|
|
438
500
|
async getAppToken() {
|
|
439
501
|
const now = Date.now() / 1e3;
|
|
440
|
-
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
502
|
+
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
503
|
+
return this.appToken.value;
|
|
441
504
|
if (!this.hasAppKey) {
|
|
442
|
-
throw new Error(
|
|
505
|
+
throw new Error(
|
|
506
|
+
"Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
|
|
507
|
+
);
|
|
443
508
|
}
|
|
444
|
-
const credentials = btoa(
|
|
509
|
+
const credentials = btoa(
|
|
510
|
+
`${this.config.m2mClientId}:${this.config.m2mClientSecret}`
|
|
511
|
+
);
|
|
445
512
|
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
446
513
|
method: "POST",
|
|
447
514
|
headers: {
|
|
@@ -457,7 +524,10 @@ var HubApi = class {
|
|
|
457
524
|
throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
|
|
458
525
|
}
|
|
459
526
|
const body = await response.json();
|
|
460
|
-
this.appToken = {
|
|
527
|
+
this.appToken = {
|
|
528
|
+
value: body.access_token,
|
|
529
|
+
expiresAt: now + body.expires_in
|
|
530
|
+
};
|
|
461
531
|
return body.access_token;
|
|
462
532
|
}
|
|
463
533
|
// Every hub that bought your app, with status and your plan id
|
|
@@ -465,11 +535,16 @@ var HubApi = class {
|
|
|
465
535
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
466
536
|
return this.subscriptions.value;
|
|
467
537
|
}
|
|
468
|
-
const response = await this.call(
|
|
469
|
-
|
|
470
|
-
|
|
538
|
+
const response = await this.call(
|
|
539
|
+
`${this.config.urls.api}/api/app-api/subscriptions`,
|
|
540
|
+
{
|
|
541
|
+
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
542
|
+
}
|
|
543
|
+
);
|
|
471
544
|
if (!response.ok) {
|
|
472
|
-
throw new Error(
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Could not read the subscriptions from Clearly.HUB (${response.status})`
|
|
547
|
+
);
|
|
473
548
|
}
|
|
474
549
|
const items = await response.json();
|
|
475
550
|
const value = items.map((item) => ({
|
|
@@ -502,12 +577,16 @@ var HubApi = class {
|
|
|
502
577
|
});
|
|
503
578
|
if (!response.ok) {
|
|
504
579
|
const body = await response.json().catch(() => ({}));
|
|
505
|
-
throw new Error(
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
|
|
582
|
+
);
|
|
506
583
|
}
|
|
507
584
|
}
|
|
508
585
|
};
|
|
509
586
|
function pickSubscription(subscriptions, hubId) {
|
|
510
|
-
const ofHub = subscriptions.filter(
|
|
587
|
+
const ofHub = subscriptions.filter(
|
|
588
|
+
(subscription) => subscription.hubId === hubId
|
|
589
|
+
);
|
|
511
590
|
return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
|
|
512
591
|
}
|
|
513
592
|
|
|
@@ -524,7 +603,12 @@ var SignIn = class {
|
|
|
524
603
|
}
|
|
525
604
|
// Discovery happens once, on the first sign-in
|
|
526
605
|
getConfiguration() {
|
|
527
|
-
this.configuration ??= oidc.discovery(
|
|
606
|
+
this.configuration ??= oidc.discovery(
|
|
607
|
+
new URL(this.config.urls.issuer),
|
|
608
|
+
this.config.clientId,
|
|
609
|
+
void 0,
|
|
610
|
+
oidc.None()
|
|
611
|
+
).catch((error) => {
|
|
528
612
|
this.configuration = void 0;
|
|
529
613
|
throw error;
|
|
530
614
|
});
|
|
@@ -546,11 +630,15 @@ var SignIn = class {
|
|
|
546
630
|
return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
|
|
547
631
|
}
|
|
548
632
|
async finish(currentUrl, pending) {
|
|
549
|
-
const tokens = await oidc.authorizationCodeGrant(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
633
|
+
const tokens = await oidc.authorizationCodeGrant(
|
|
634
|
+
await this.getConfiguration(),
|
|
635
|
+
currentUrl,
|
|
636
|
+
{
|
|
637
|
+
pkceCodeVerifier: pending.codeVerifier,
|
|
638
|
+
expectedState: pending.state,
|
|
639
|
+
expectedNonce: pending.nonce
|
|
640
|
+
}
|
|
641
|
+
);
|
|
554
642
|
const claims = tokens.claims();
|
|
555
643
|
if (!claims) throw new Error("Clearly.HUB sent no ID token");
|
|
556
644
|
return {
|
|
@@ -563,7 +651,10 @@ var SignIn = class {
|
|
|
563
651
|
};
|
|
564
652
|
}
|
|
565
653
|
async refresh(refreshToken) {
|
|
566
|
-
const tokens = await oidc.refreshTokenGrant(
|
|
654
|
+
const tokens = await oidc.refreshTokenGrant(
|
|
655
|
+
await this.getConfiguration(),
|
|
656
|
+
refreshToken
|
|
657
|
+
);
|
|
567
658
|
return toSessionTokens(tokens, refreshToken);
|
|
568
659
|
}
|
|
569
660
|
// Signing out of Clearly.HUB too, or the next sign-in goes straight through
|
|
@@ -596,12 +687,16 @@ function parseCookies(header) {
|
|
|
596
687
|
const cookies = /* @__PURE__ */ new Map();
|
|
597
688
|
for (const part of header.split(";")) {
|
|
598
689
|
const index = part.indexOf("=");
|
|
599
|
-
if (index > 0)
|
|
690
|
+
if (index > 0)
|
|
691
|
+
cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
|
|
600
692
|
}
|
|
601
693
|
return cookies;
|
|
602
694
|
}
|
|
603
695
|
async function deriveKey(secret) {
|
|
604
|
-
const digest = await crypto.subtle.digest(
|
|
696
|
+
const digest = await crypto.subtle.digest(
|
|
697
|
+
"SHA-256",
|
|
698
|
+
new TextEncoder().encode(secret)
|
|
699
|
+
);
|
|
605
700
|
return new Uint8Array(digest);
|
|
606
701
|
}
|
|
607
702
|
var SessionStore = class {
|
|
@@ -619,8 +714,14 @@ var SessionStore = class {
|
|
|
619
714
|
}
|
|
620
715
|
if (!chunks.length) return { data: {}, chunkCount: 0 };
|
|
621
716
|
try {
|
|
622
|
-
const { plaintext } = await (0, import_jose.compactDecrypt)(
|
|
623
|
-
|
|
717
|
+
const { plaintext } = await (0, import_jose.compactDecrypt)(
|
|
718
|
+
chunks.join(""),
|
|
719
|
+
await this.keyPromise
|
|
720
|
+
);
|
|
721
|
+
return {
|
|
722
|
+
data: JSON.parse(new TextDecoder().decode(plaintext)),
|
|
723
|
+
chunkCount: chunks.length
|
|
724
|
+
};
|
|
624
725
|
} catch {
|
|
625
726
|
return { data: {}, chunkCount: chunks.length };
|
|
626
727
|
}
|
|
@@ -628,7 +729,9 @@ var SessionStore = class {
|
|
|
628
729
|
// Set-Cookie headers for the new session, clearing chunks it no longer needs
|
|
629
730
|
async write(data, previousChunkCount) {
|
|
630
731
|
const isEmpty = Object.keys(data).length === 0;
|
|
631
|
-
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
732
|
+
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
733
|
+
new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
|
|
734
|
+
);
|
|
632
735
|
const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
|
|
633
736
|
const headers = chunks.map(
|
|
634
737
|
(chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
|
|
@@ -678,7 +781,8 @@ function clearlyHub(options) {
|
|
|
678
781
|
// person's key. Open data works without it.
|
|
679
782
|
async fetchAsUser(request, url, init = {}) {
|
|
680
783
|
const token = await getUserToken(request);
|
|
681
|
-
if (!token)
|
|
784
|
+
if (!token)
|
|
785
|
+
throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
|
|
682
786
|
const headers = new Headers(init.headers);
|
|
683
787
|
headers.set("Authorization", token);
|
|
684
788
|
return config.fetch(url, { ...init, headers });
|
|
@@ -703,11 +807,18 @@ function clearlyHub(options) {
|
|
|
703
807
|
}) {
|
|
704
808
|
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
705
809
|
if (!person) {
|
|
706
|
-
throw new Error(
|
|
810
|
+
throw new Error(
|
|
811
|
+
"Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
|
|
812
|
+
);
|
|
707
813
|
}
|
|
708
|
-
const subscription = pickSubscription(
|
|
814
|
+
const subscription = pickSubscription(
|
|
815
|
+
await parts.api.getSubscriptions(),
|
|
816
|
+
hubId
|
|
817
|
+
);
|
|
709
818
|
if (subscription?.status !== "ACTIVE") {
|
|
710
|
-
throw new Error(
|
|
819
|
+
throw new Error(
|
|
820
|
+
`Clearly.HUB: hub ${hubId} has no active subscription to your app`
|
|
821
|
+
);
|
|
711
822
|
}
|
|
712
823
|
await parts.api.reportUsage({
|
|
713
824
|
sub: person,
|
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
|
-
|
|
94
|
-
|
|
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 {
|
|
@@ -179,4 +184,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
|
179
184
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
180
185
|
|
|
181
186
|
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 };
|
|
187
|
+
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
|
-
|
|
94
|
-
|
|
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 {
|
|
@@ -179,4 +184,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
|
179
184
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
180
185
|
|
|
181
186
|
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 };
|
|
187
|
+
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) {
|
|
@@ -84,11 +85,15 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
84
85
|
try {
|
|
85
86
|
const headers = new Headers();
|
|
86
87
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
87
|
-
if (Array.isArray(value))
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
value.forEach((item) => headers.append(name, item));
|
|
88
90
|
else if (value !== void 0) headers.set(name, value);
|
|
89
91
|
}
|
|
90
92
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
91
|
-
const response = await handle(
|
|
93
|
+
const response = await handle(
|
|
94
|
+
new Request(url, { method: req.method, headers }),
|
|
95
|
+
req
|
|
96
|
+
);
|
|
92
97
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
93
98
|
next();
|
|
94
99
|
return;
|
|
@@ -142,16 +147,24 @@ var Screens = class {
|
|
|
142
147
|
base64Decode(signature),
|
|
143
148
|
new TextEncoder().encode(data)
|
|
144
149
|
);
|
|
145
|
-
if (!isSigned)
|
|
146
|
-
|
|
150
|
+
if (!isSigned)
|
|
151
|
+
throw new AnswerError("This answer was not signed by Clearly.HUB");
|
|
152
|
+
const { request, response } = JSON.parse(
|
|
153
|
+
new TextDecoder().decode(base64Decode(data))
|
|
154
|
+
);
|
|
147
155
|
return { state: request.state, response };
|
|
148
156
|
}
|
|
149
157
|
// Fetched once; a key change needs a restart
|
|
150
158
|
getPublicKey() {
|
|
151
159
|
this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
|
|
152
|
-
if (!response.ok)
|
|
160
|
+
if (!response.ok)
|
|
161
|
+
throw new Error(`No public key at ${this.config.urls.publicKey}`);
|
|
153
162
|
return response.text();
|
|
154
|
-
}).then(
|
|
163
|
+
}).then(
|
|
164
|
+
(pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
|
|
165
|
+
"verify"
|
|
166
|
+
])
|
|
167
|
+
).catch((error) => {
|
|
155
168
|
this.publicKey = void 0;
|
|
156
169
|
throw error;
|
|
157
170
|
});
|
|
@@ -162,7 +175,8 @@ var AnswerError = class extends Error {
|
|
|
162
175
|
};
|
|
163
176
|
function toScreenResult(action, response) {
|
|
164
177
|
const { result, ...answer } = response;
|
|
165
|
-
if (result === "SUCCESS")
|
|
178
|
+
if (result === "SUCCESS")
|
|
179
|
+
return { action, result: "SUCCESS", answer };
|
|
166
180
|
return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
|
|
167
181
|
}
|
|
168
182
|
function base64Encode(text) {
|
|
@@ -178,7 +192,11 @@ function readRawQuery(url) {
|
|
|
178
192
|
const params = /* @__PURE__ */ new Map();
|
|
179
193
|
for (const pair of url.search.slice(1).split("&")) {
|
|
180
194
|
const index = pair.indexOf("=");
|
|
181
|
-
if (index > 0)
|
|
195
|
+
if (index > 0)
|
|
196
|
+
params.set(
|
|
197
|
+
pair.slice(0, index),
|
|
198
|
+
decodeURIComponent(pair.slice(index + 1))
|
|
199
|
+
);
|
|
182
200
|
}
|
|
183
201
|
return params;
|
|
184
202
|
}
|
|
@@ -192,7 +210,8 @@ function createHandler(parts) {
|
|
|
192
210
|
const url = new URL(request.url);
|
|
193
211
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
194
212
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
195
|
-
if (request.method !== "GET")
|
|
213
|
+
if (request.method !== "GET")
|
|
214
|
+
return new Response("Method not allowed", { status: 405 });
|
|
196
215
|
const host = request.headers.get("host");
|
|
197
216
|
const appHost = new URL(config.appUrl).host;
|
|
198
217
|
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
@@ -205,7 +224,8 @@ function createHandler(parts) {
|
|
|
205
224
|
response = await routeRequest(route, url, data, parts, { request, req });
|
|
206
225
|
} catch (error) {
|
|
207
226
|
const isRefusedAnswer = error instanceof AnswerError;
|
|
208
|
-
if (!isRefusedAnswer)
|
|
227
|
+
if (!isRefusedAnswer)
|
|
228
|
+
console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
209
229
|
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
210
230
|
}
|
|
211
231
|
if (JSON.stringify(data) !== before) {
|
|
@@ -223,7 +243,8 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
223
243
|
case ROUTES.callback:
|
|
224
244
|
return finishSignIn(url, session, parts, incoming);
|
|
225
245
|
case ROUTES.signOut:
|
|
226
|
-
for (const key of Object.keys(session))
|
|
246
|
+
for (const key of Object.keys(session))
|
|
247
|
+
delete session[key];
|
|
227
248
|
return redirect(parts.signIn.signOutUrl());
|
|
228
249
|
case ROUTES.session:
|
|
229
250
|
return json(await readSession(session, parts));
|
|
@@ -236,12 +257,16 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
236
257
|
delete session.result;
|
|
237
258
|
return json(result);
|
|
238
259
|
}
|
|
260
|
+
case ROUTES.token:
|
|
261
|
+
return readToken(session, parts);
|
|
239
262
|
default:
|
|
240
263
|
return notFound();
|
|
241
264
|
}
|
|
242
265
|
}
|
|
243
266
|
async function startSignIn(url, session, { config, signIn }) {
|
|
244
|
-
const { url: authorizationUrl, pending } = await signIn.start(
|
|
267
|
+
const { url: authorizationUrl, pending } = await signIn.start(
|
|
268
|
+
safeReturnTo(url, config)
|
|
269
|
+
);
|
|
245
270
|
session.signIn = pending;
|
|
246
271
|
return redirect(authorizationUrl);
|
|
247
272
|
}
|
|
@@ -255,13 +280,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
|
255
280
|
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
256
281
|
session.user = { ...person, appUserId };
|
|
257
282
|
session.tokens = tokens;
|
|
258
|
-
return redirect(
|
|
283
|
+
return redirect(
|
|
284
|
+
`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
|
|
285
|
+
);
|
|
259
286
|
}
|
|
260
287
|
async function readSession(session, parts) {
|
|
261
288
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
262
289
|
if (!session.user || !accessToken) return { signedIn: false };
|
|
263
290
|
return { signedIn: true, user: session.user };
|
|
264
291
|
}
|
|
292
|
+
async function readToken(session, parts) {
|
|
293
|
+
const accessToken = await getFreshAccessToken(session, parts);
|
|
294
|
+
if (!session.user || !accessToken)
|
|
295
|
+
return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
|
|
296
|
+
const token = {
|
|
297
|
+
accessToken,
|
|
298
|
+
expiresIn: Math.max(
|
|
299
|
+
0,
|
|
300
|
+
session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
|
|
301
|
+
)
|
|
302
|
+
};
|
|
303
|
+
return json(token);
|
|
304
|
+
}
|
|
265
305
|
async function getFreshAccessToken(session, { signIn }) {
|
|
266
306
|
const tokens = session.tokens;
|
|
267
307
|
if (!tokens) return void 0;
|
|
@@ -278,7 +318,8 @@ async function getFreshAccessToken(session, { signIn }) {
|
|
|
278
318
|
}
|
|
279
319
|
function openScreen(url, session, { config, screens }) {
|
|
280
320
|
const action = url.searchParams.get("action");
|
|
281
|
-
if (!isScreenAction(action))
|
|
321
|
+
if (!isScreenAction(action))
|
|
322
|
+
return errorPage(400, `Unknown screen: ${action}`);
|
|
282
323
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
283
324
|
const dataParam = url.searchParams.get("data");
|
|
284
325
|
let data;
|
|
@@ -309,7 +350,9 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
309
350
|
);
|
|
310
351
|
const pending = state ? session.screens?.[state] : void 0;
|
|
311
352
|
if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
|
|
312
|
-
throw new AnswerError(
|
|
353
|
+
throw new AnswerError(
|
|
354
|
+
"This answer does not belong to a screen opened in this browser"
|
|
355
|
+
);
|
|
313
356
|
}
|
|
314
357
|
delete session.screens[state];
|
|
315
358
|
let result = toScreenResult(pending.action, response);
|
|
@@ -321,7 +364,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
321
364
|
});
|
|
322
365
|
if (app !== void 0) result = { ...result, app };
|
|
323
366
|
} catch (error) {
|
|
324
|
-
result = {
|
|
367
|
+
result = {
|
|
368
|
+
action: pending.action,
|
|
369
|
+
result: "FAILURE",
|
|
370
|
+
error: error.message
|
|
371
|
+
};
|
|
325
372
|
}
|
|
326
373
|
if (pending.display === "redirect") {
|
|
327
374
|
session.result = { ref: pending.ref, result };
|
|
@@ -356,11 +403,21 @@ function safePath(path, config) {
|
|
|
356
403
|
}
|
|
357
404
|
}
|
|
358
405
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
359
|
-
var json = (body) => new Response(JSON.stringify(body), {
|
|
360
|
-
|
|
406
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
407
|
+
status,
|
|
408
|
+
headers: {
|
|
409
|
+
"Content-Type": "application/json",
|
|
410
|
+
"Cache-Control": "no-store"
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
var html = (body, status = 200) => new Response(body, {
|
|
414
|
+
status,
|
|
415
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
416
|
+
});
|
|
417
|
+
var notFound = () => new Response("Not found", {
|
|
418
|
+
status: 404,
|
|
419
|
+
headers: { "X-Clearly-Hub": "unhandled" }
|
|
361
420
|
});
|
|
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
421
|
function describeError(error) {
|
|
365
422
|
const messages = [];
|
|
366
423
|
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
@@ -369,7 +426,10 @@ function describeError(error) {
|
|
|
369
426
|
return messages.join(": ") || String(error);
|
|
370
427
|
}
|
|
371
428
|
function errorPage(status, message) {
|
|
372
|
-
const escaped = message.replace(
|
|
429
|
+
const escaped = message.replace(
|
|
430
|
+
/[&<>"]/g,
|
|
431
|
+
(char) => `&#${char.charCodeAt(0)};`
|
|
432
|
+
);
|
|
373
433
|
return html(
|
|
374
434
|
`<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
|
|
375
435
|
status
|
|
@@ -391,7 +451,9 @@ var HubApi = class {
|
|
|
391
451
|
try {
|
|
392
452
|
return await this.config.fetch(url, init);
|
|
393
453
|
} catch (error) {
|
|
394
|
-
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
454
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
455
|
+
cause: error
|
|
456
|
+
});
|
|
395
457
|
}
|
|
396
458
|
}
|
|
397
459
|
get hasAppKey() {
|
|
@@ -401,11 +463,16 @@ var HubApi = class {
|
|
|
401
463
|
// until shortly before it expires
|
|
402
464
|
async getAppToken() {
|
|
403
465
|
const now = Date.now() / 1e3;
|
|
404
|
-
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
466
|
+
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
467
|
+
return this.appToken.value;
|
|
405
468
|
if (!this.hasAppKey) {
|
|
406
|
-
throw new Error(
|
|
469
|
+
throw new Error(
|
|
470
|
+
"Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
|
|
471
|
+
);
|
|
407
472
|
}
|
|
408
|
-
const credentials = btoa(
|
|
473
|
+
const credentials = btoa(
|
|
474
|
+
`${this.config.m2mClientId}:${this.config.m2mClientSecret}`
|
|
475
|
+
);
|
|
409
476
|
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
410
477
|
method: "POST",
|
|
411
478
|
headers: {
|
|
@@ -421,7 +488,10 @@ var HubApi = class {
|
|
|
421
488
|
throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
|
|
422
489
|
}
|
|
423
490
|
const body = await response.json();
|
|
424
|
-
this.appToken = {
|
|
491
|
+
this.appToken = {
|
|
492
|
+
value: body.access_token,
|
|
493
|
+
expiresAt: now + body.expires_in
|
|
494
|
+
};
|
|
425
495
|
return body.access_token;
|
|
426
496
|
}
|
|
427
497
|
// Every hub that bought your app, with status and your plan id
|
|
@@ -429,11 +499,16 @@ var HubApi = class {
|
|
|
429
499
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
430
500
|
return this.subscriptions.value;
|
|
431
501
|
}
|
|
432
|
-
const response = await this.call(
|
|
433
|
-
|
|
434
|
-
|
|
502
|
+
const response = await this.call(
|
|
503
|
+
`${this.config.urls.api}/api/app-api/subscriptions`,
|
|
504
|
+
{
|
|
505
|
+
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
506
|
+
}
|
|
507
|
+
);
|
|
435
508
|
if (!response.ok) {
|
|
436
|
-
throw new Error(
|
|
509
|
+
throw new Error(
|
|
510
|
+
`Could not read the subscriptions from Clearly.HUB (${response.status})`
|
|
511
|
+
);
|
|
437
512
|
}
|
|
438
513
|
const items = await response.json();
|
|
439
514
|
const value = items.map((item) => ({
|
|
@@ -466,12 +541,16 @@ var HubApi = class {
|
|
|
466
541
|
});
|
|
467
542
|
if (!response.ok) {
|
|
468
543
|
const body = await response.json().catch(() => ({}));
|
|
469
|
-
throw new Error(
|
|
544
|
+
throw new Error(
|
|
545
|
+
`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
|
|
546
|
+
);
|
|
470
547
|
}
|
|
471
548
|
}
|
|
472
549
|
};
|
|
473
550
|
function pickSubscription(subscriptions, hubId) {
|
|
474
|
-
const ofHub = subscriptions.filter(
|
|
551
|
+
const ofHub = subscriptions.filter(
|
|
552
|
+
(subscription) => subscription.hubId === hubId
|
|
553
|
+
);
|
|
475
554
|
return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
|
|
476
555
|
}
|
|
477
556
|
|
|
@@ -488,7 +567,12 @@ var SignIn = class {
|
|
|
488
567
|
}
|
|
489
568
|
// Discovery happens once, on the first sign-in
|
|
490
569
|
getConfiguration() {
|
|
491
|
-
this.configuration ??= oidc.discovery(
|
|
570
|
+
this.configuration ??= oidc.discovery(
|
|
571
|
+
new URL(this.config.urls.issuer),
|
|
572
|
+
this.config.clientId,
|
|
573
|
+
void 0,
|
|
574
|
+
oidc.None()
|
|
575
|
+
).catch((error) => {
|
|
492
576
|
this.configuration = void 0;
|
|
493
577
|
throw error;
|
|
494
578
|
});
|
|
@@ -510,11 +594,15 @@ var SignIn = class {
|
|
|
510
594
|
return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
|
|
511
595
|
}
|
|
512
596
|
async finish(currentUrl, pending) {
|
|
513
|
-
const tokens = await oidc.authorizationCodeGrant(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
597
|
+
const tokens = await oidc.authorizationCodeGrant(
|
|
598
|
+
await this.getConfiguration(),
|
|
599
|
+
currentUrl,
|
|
600
|
+
{
|
|
601
|
+
pkceCodeVerifier: pending.codeVerifier,
|
|
602
|
+
expectedState: pending.state,
|
|
603
|
+
expectedNonce: pending.nonce
|
|
604
|
+
}
|
|
605
|
+
);
|
|
518
606
|
const claims = tokens.claims();
|
|
519
607
|
if (!claims) throw new Error("Clearly.HUB sent no ID token");
|
|
520
608
|
return {
|
|
@@ -527,7 +615,10 @@ var SignIn = class {
|
|
|
527
615
|
};
|
|
528
616
|
}
|
|
529
617
|
async refresh(refreshToken) {
|
|
530
|
-
const tokens = await oidc.refreshTokenGrant(
|
|
618
|
+
const tokens = await oidc.refreshTokenGrant(
|
|
619
|
+
await this.getConfiguration(),
|
|
620
|
+
refreshToken
|
|
621
|
+
);
|
|
531
622
|
return toSessionTokens(tokens, refreshToken);
|
|
532
623
|
}
|
|
533
624
|
// Signing out of Clearly.HUB too, or the next sign-in goes straight through
|
|
@@ -560,12 +651,16 @@ function parseCookies(header) {
|
|
|
560
651
|
const cookies = /* @__PURE__ */ new Map();
|
|
561
652
|
for (const part of header.split(";")) {
|
|
562
653
|
const index = part.indexOf("=");
|
|
563
|
-
if (index > 0)
|
|
654
|
+
if (index > 0)
|
|
655
|
+
cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
|
|
564
656
|
}
|
|
565
657
|
return cookies;
|
|
566
658
|
}
|
|
567
659
|
async function deriveKey(secret) {
|
|
568
|
-
const digest = await crypto.subtle.digest(
|
|
660
|
+
const digest = await crypto.subtle.digest(
|
|
661
|
+
"SHA-256",
|
|
662
|
+
new TextEncoder().encode(secret)
|
|
663
|
+
);
|
|
569
664
|
return new Uint8Array(digest);
|
|
570
665
|
}
|
|
571
666
|
var SessionStore = class {
|
|
@@ -583,8 +678,14 @@ var SessionStore = class {
|
|
|
583
678
|
}
|
|
584
679
|
if (!chunks.length) return { data: {}, chunkCount: 0 };
|
|
585
680
|
try {
|
|
586
|
-
const { plaintext } = await compactDecrypt(
|
|
587
|
-
|
|
681
|
+
const { plaintext } = await compactDecrypt(
|
|
682
|
+
chunks.join(""),
|
|
683
|
+
await this.keyPromise
|
|
684
|
+
);
|
|
685
|
+
return {
|
|
686
|
+
data: JSON.parse(new TextDecoder().decode(plaintext)),
|
|
687
|
+
chunkCount: chunks.length
|
|
688
|
+
};
|
|
588
689
|
} catch {
|
|
589
690
|
return { data: {}, chunkCount: chunks.length };
|
|
590
691
|
}
|
|
@@ -592,7 +693,9 @@ var SessionStore = class {
|
|
|
592
693
|
// Set-Cookie headers for the new session, clearing chunks it no longer needs
|
|
593
694
|
async write(data, previousChunkCount) {
|
|
594
695
|
const isEmpty = Object.keys(data).length === 0;
|
|
595
|
-
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
696
|
+
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
697
|
+
new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
|
|
698
|
+
);
|
|
596
699
|
const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
|
|
597
700
|
const headers = chunks.map(
|
|
598
701
|
(chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
|
|
@@ -642,7 +745,8 @@ function clearlyHub(options) {
|
|
|
642
745
|
// person's key. Open data works without it.
|
|
643
746
|
async fetchAsUser(request, url, init = {}) {
|
|
644
747
|
const token = await getUserToken(request);
|
|
645
|
-
if (!token)
|
|
748
|
+
if (!token)
|
|
749
|
+
throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
|
|
646
750
|
const headers = new Headers(init.headers);
|
|
647
751
|
headers.set("Authorization", token);
|
|
648
752
|
return config.fetch(url, { ...init, headers });
|
|
@@ -667,11 +771,18 @@ function clearlyHub(options) {
|
|
|
667
771
|
}) {
|
|
668
772
|
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
669
773
|
if (!person) {
|
|
670
|
-
throw new Error(
|
|
774
|
+
throw new Error(
|
|
775
|
+
"Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
|
|
776
|
+
);
|
|
671
777
|
}
|
|
672
|
-
const subscription = pickSubscription(
|
|
778
|
+
const subscription = pickSubscription(
|
|
779
|
+
await parts.api.getSubscriptions(),
|
|
780
|
+
hubId
|
|
781
|
+
);
|
|
673
782
|
if (subscription?.status !== "ACTIVE") {
|
|
674
|
-
throw new Error(
|
|
783
|
+
throw new Error(
|
|
784
|
+
`Clearly.HUB: hub ${hubId} has no active subscription to your app`
|
|
785
|
+
);
|
|
675
786
|
}
|
|
676
787
|
await parts.api.reportUsage({
|
|
677
788
|
sub: person,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clearly-hub/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Clearly.HUB for Node servers: sign-in, hosted screens, subscriptions and usage in one handler",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Future Insight Group BV",
|