@clearly-hub/node 0.1.0 → 0.2.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 +54 -34
- package/dist/index.cjs +89 -33
- package/dist/index.d.cts +25 -11
- package/dist/index.d.ts +25 -11
- package/dist/index.js +89 -33
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -11,13 +11,13 @@ hub) and each hub's subscription to your app. Tokens stay on your server.
|
|
|
11
11
|
|
|
12
12
|
**1. Register your app in Clearly.HUB** (on your app's page, no code):
|
|
13
13
|
|
|
14
|
-
| Setting
|
|
15
|
-
|
|
|
16
|
-
| Go to app
|
|
14
|
+
| Setting | Value |
|
|
15
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| Go to app | `https://app.example.com/clearly-hub/sign-in` |
|
|
17
17
|
| Sign-in client (H2M): callback URLs | `https://app.example.com/clearly-hub/callback`, `https://app.example.com/clearly-hub/answer`, `https://hub.clearly.app/components` |
|
|
18
|
-
| Sign-in client: sign-out URLs
|
|
19
|
-
| M2M client
|
|
20
|
-
| Plans
|
|
18
|
+
| Sign-in client: sign-out URLs | `https://app.example.com`, `https://hub.clearly.app/components` |
|
|
19
|
+
| M2M client | create it, keep the secret on your server |
|
|
20
|
+
| Plans | give each your own plan id as its _External reference_, for example `pro` |
|
|
21
21
|
|
|
22
22
|
**2. Mount the server handler** (Node 20+):
|
|
23
23
|
|
|
@@ -43,7 +43,8 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
|
|
|
43
43
|
**3. Add the script and a button:**
|
|
44
44
|
|
|
45
45
|
```html
|
|
46
|
-
|
|
46
|
+
<!-- @0.1: follows 0.1.x fixes, not a breaking 0.2 -->
|
|
47
|
+
<script src="https://cdn.jsdelivr.net/npm/@clearly-hub/browser@0.1/dist/clearly-hub.js"></script>
|
|
47
48
|
|
|
48
49
|
<clearly-hub-button action="sign-in"></clearly-hub-button>
|
|
49
50
|
<clearly-hub-button action="DATA_PICKER"></clearly-hub-button>
|
|
@@ -54,18 +55,18 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
|
|
|
54
55
|
</script>
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-hub/browser'`.
|
|
58
|
+
Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-hub/browser'`. With React and TypeScript, add `import '@clearly-hub/browser/react'` once, so JSX knows `<clearly-hub-button>`.
|
|
58
59
|
|
|
59
60
|
## The browser package
|
|
60
61
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| `clearlyHub.session()`
|
|
64
|
-
| `clearlyHub.signIn()`, `signOut()`
|
|
65
|
-
| `clearlyHub.open(action, { data, display })`
|
|
66
|
-
| `clearlyHub.result()`
|
|
67
|
-
| `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen
|
|
68
|
-
| `clearly-hub-answer` event
|
|
62
|
+
| | |
|
|
63
|
+
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
64
|
+
| `clearlyHub.session()` | `{ signedIn: false }`, or the signed-in user (`sub`, `email`, `name`, `appUserId`) |
|
|
65
|
+
| `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
|
|
66
|
+
| `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
|
|
67
|
+
| `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
|
|
68
|
+
| `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
|
|
69
|
+
| `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
|
|
69
70
|
|
|
70
71
|
**Styling the button.** It uses your page's font. Attributes for the common cases:
|
|
71
72
|
`variant="outline"`, `size="small"` or `size="large"`. CSS custom properties for your look:
|
|
@@ -73,32 +74,51 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
|
|
|
73
74
|
`--clearly-hub-font`. For anything else, style the real button through `::part(button)`:
|
|
74
75
|
|
|
75
76
|
```css
|
|
76
|
-
clearly-hub-button {
|
|
77
|
-
clearly-hub-
|
|
77
|
+
clearly-hub-button {
|
|
78
|
+
--clearly-hub-color: #0b7471;
|
|
79
|
+
--clearly-hub-radius: 999px;
|
|
80
|
+
}
|
|
81
|
+
clearly-hub-button::part(button) {
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
}
|
|
78
84
|
```
|
|
79
85
|
|
|
80
86
|
Screens open in a popup by default (`data-display="redirect"` on the script tag changes that). A
|
|
81
87
|
blocked popup falls back to a redirect.
|
|
82
88
|
|
|
83
|
-
| Action
|
|
84
|
-
|
|
|
85
|
-
| `SELECT_SUBSCRIPTION` |
|
|
86
|
-
| `DATA_PICKER`
|
|
87
|
-
| `REGISTER_RESOURCE`
|
|
88
|
-
| `SELECT_HUB`
|
|
89
|
+
| Action | Send in `data` | `answer` when `result` is `SUCCESS` |
|
|
90
|
+
| --------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
|
|
92
|
+
| `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
|
|
93
|
+
| `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
|
|
94
|
+
| `SELECT_HUB` | | `hub` (`_id`, `name`) |
|
|
89
95
|
|
|
90
96
|
## The Node package
|
|
91
97
|
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
| `hub.express()`, `hub.handler`
|
|
95
|
-
| `onSignIn(person)`
|
|
96
|
-
| `onAnswer(result, { user })`
|
|
97
|
-
| `hub.getUser(req)`
|
|
98
|
-
| `hub.fetchAsUser(req, url)`
|
|
99
|
-
| `hub.getSubscription(hubId)`, `getSubscriptions()`
|
|
100
|
-
| `hub.reportUsage({ request, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub |
|
|
101
|
-
| `hub.getUserToken(req)`, `getAppToken()`
|
|
98
|
+
| | |
|
|
99
|
+
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
100
|
+
| `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
|
|
101
|
+
| `onSignIn(person, { request, req })` | Called after each sign-in. Return your own user id to keep it in the session, or `{ appUserId, redirectTo }` to also choose the next page |
|
|
102
|
+
| `onAnswer(result, { user, data, request, req })` | Called with each checked answer before it reaches the browser, for example to set up a workspace. `data` is what the screen was sent; what you return reaches the page as `result.app` |
|
|
103
|
+
| `hub.getUser(req)` | Who's signed in, in your own routes |
|
|
104
|
+
| `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
|
|
105
|
+
| `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
|
|
106
|
+
| `hub.reportUsage({ request or sub, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub. Pass `sub` for a Clearly.HUB account you stored, without a live sign-in |
|
|
107
|
+
| `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
|
|
108
|
+
|
|
109
|
+
**Your own session in the hooks.** With `hub.express()`, the hooks get Express's `req` as
|
|
110
|
+
`context.req`, so they can use your own session (for example express-session) alongside Clearly.HUB's:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const hub = clearlyHub<express.Request>({
|
|
114
|
+
// ...
|
|
115
|
+
onSignIn: (person, { req }) => {
|
|
116
|
+
const user = findOrCreateUser(person);
|
|
117
|
+
req!.session.userId = user.id;
|
|
118
|
+
return { appUserId: user.id };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
```
|
|
102
122
|
|
|
103
123
|
**What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
|
|
104
124
|
opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
|
package/dist/index.cjs
CHANGED
|
@@ -44,6 +44,23 @@ var SCREEN_ACTIONS = [
|
|
|
44
44
|
function isScreenAction(value) {
|
|
45
45
|
return SCREEN_ACTIONS.includes(value);
|
|
46
46
|
}
|
|
47
|
+
function checkScreenData(action, data) {
|
|
48
|
+
if (action !== "REGISTER_RESOURCE") return void 0;
|
|
49
|
+
const { url, format } = data ?? {};
|
|
50
|
+
if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
|
|
51
|
+
return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
|
|
52
|
+
}
|
|
53
|
+
let hostname;
|
|
54
|
+
try {
|
|
55
|
+
hostname = new URL(url).hostname;
|
|
56
|
+
} catch {
|
|
57
|
+
return `REGISTER_RESOURCE: "${url}" is not a URL`;
|
|
58
|
+
}
|
|
59
|
+
if (!hostname.includes(".")) {
|
|
60
|
+
return "REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost";
|
|
61
|
+
}
|
|
62
|
+
return void 0;
|
|
63
|
+
}
|
|
47
64
|
|
|
48
65
|
// ../shared/src/protocol.ts
|
|
49
66
|
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
@@ -91,7 +108,8 @@ function resolveConfig(options) {
|
|
|
91
108
|
signedOutUrl: options.signedOutUrl ?? appUrl,
|
|
92
109
|
onSignIn: options.onSignIn,
|
|
93
110
|
onAnswer: options.onAnswer,
|
|
94
|
-
|
|
111
|
+
// Looked up on every call, so a fetch wrapped later (logging, tracing) is used
|
|
112
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
95
113
|
isSecure: appUrl.startsWith("https://")
|
|
96
114
|
};
|
|
97
115
|
}
|
|
@@ -106,7 +124,7 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
106
124
|
else if (value !== void 0) headers.set(name, value);
|
|
107
125
|
}
|
|
108
126
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
109
|
-
const response = await handle(new Request(url, { method: req.method, headers }));
|
|
127
|
+
const response = await handle(new Request(url, { method: req.method, headers }), req);
|
|
110
128
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
111
129
|
next();
|
|
112
130
|
return;
|
|
@@ -206,18 +224,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
|
|
|
206
224
|
var MAX_PENDING_SCREENS = 10;
|
|
207
225
|
function createHandler(parts) {
|
|
208
226
|
const { config, sessions } = parts;
|
|
209
|
-
return async function handle(request) {
|
|
227
|
+
return async function handle(request, req) {
|
|
210
228
|
const url = new URL(request.url);
|
|
211
229
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
212
230
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
213
231
|
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
|
232
|
+
const host = request.headers.get("host");
|
|
233
|
+
const appHost = new URL(config.appUrl).host;
|
|
234
|
+
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
235
|
+
return redirect(`${config.appUrl}${url.pathname}${url.search}`);
|
|
236
|
+
}
|
|
214
237
|
const { data, chunkCount } = await sessions.read(request);
|
|
215
238
|
const before = JSON.stringify(data);
|
|
216
239
|
let response;
|
|
217
240
|
try {
|
|
218
|
-
response = await routeRequest(route, url, data, parts);
|
|
241
|
+
response = await routeRequest(route, url, data, parts, { request, req });
|
|
219
242
|
} catch (error) {
|
|
220
|
-
|
|
243
|
+
const isRefusedAnswer = error instanceof AnswerError;
|
|
244
|
+
if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
245
|
+
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
221
246
|
}
|
|
222
247
|
if (JSON.stringify(data) !== before) {
|
|
223
248
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -227,12 +252,12 @@ function createHandler(parts) {
|
|
|
227
252
|
return response;
|
|
228
253
|
};
|
|
229
254
|
}
|
|
230
|
-
async function routeRequest(route, url, session, parts) {
|
|
255
|
+
async function routeRequest(route, url, session, parts, incoming) {
|
|
231
256
|
switch (route) {
|
|
232
257
|
case ROUTES.signIn:
|
|
233
258
|
return startSignIn(url, session, parts);
|
|
234
259
|
case ROUTES.callback:
|
|
235
|
-
return finishSignIn(url, session, parts);
|
|
260
|
+
return finishSignIn(url, session, parts, incoming);
|
|
236
261
|
case ROUTES.signOut:
|
|
237
262
|
for (const key of Object.keys(session)) delete session[key];
|
|
238
263
|
return redirect(parts.signIn.signOutUrl());
|
|
@@ -241,7 +266,7 @@ async function routeRequest(route, url, session, parts) {
|
|
|
241
266
|
case ROUTES.open:
|
|
242
267
|
return openScreen(url, session, parts);
|
|
243
268
|
case ROUTES.answer:
|
|
244
|
-
return receiveAnswer(url, session, parts);
|
|
269
|
+
return receiveAnswer(url, session, parts, incoming);
|
|
245
270
|
case ROUTES.result: {
|
|
246
271
|
const result = session.result ?? null;
|
|
247
272
|
delete session.result;
|
|
@@ -256,16 +281,17 @@ async function startSignIn(url, session, { config, signIn }) {
|
|
|
256
281
|
session.signIn = pending;
|
|
257
282
|
return redirect(authorizationUrl);
|
|
258
283
|
}
|
|
259
|
-
async function finishSignIn(url, session, { config, signIn }) {
|
|
284
|
+
async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
260
285
|
const pending = session.signIn;
|
|
261
286
|
if (!pending) return redirect(config.appUrl);
|
|
262
287
|
delete session.signIn;
|
|
263
288
|
const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
|
|
264
289
|
const { person, tokens } = await signIn.finish(currentUrl, pending);
|
|
265
|
-
const
|
|
290
|
+
const decision = await config.onSignIn?.(person, incoming);
|
|
291
|
+
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
266
292
|
session.user = { ...person, appUserId };
|
|
267
293
|
session.tokens = tokens;
|
|
268
|
-
return redirect(`${config.appUrl}${pending.returnTo}`);
|
|
294
|
+
return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
|
|
269
295
|
}
|
|
270
296
|
async function readSession(session, parts) {
|
|
271
297
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
@@ -291,6 +317,14 @@ function openScreen(url, session, { config, screens }) {
|
|
|
291
317
|
if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
|
|
292
318
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
293
319
|
const dataParam = url.searchParams.get("data");
|
|
320
|
+
let data;
|
|
321
|
+
try {
|
|
322
|
+
data = dataParam ? JSON.parse(dataParam) : void 0;
|
|
323
|
+
} catch {
|
|
324
|
+
return errorPage(400, "The screen data is not valid JSON");
|
|
325
|
+
}
|
|
326
|
+
const dataProblem = checkScreenData(action, data);
|
|
327
|
+
if (dataProblem) return errorPage(400, dataProblem);
|
|
294
328
|
const state = crypto.randomUUID();
|
|
295
329
|
session.screens = pruneScreens(session.screens);
|
|
296
330
|
session.screens[state] = {
|
|
@@ -298,18 +332,12 @@ function openScreen(url, session, { config, screens }) {
|
|
|
298
332
|
display,
|
|
299
333
|
ref: url.searchParams.get("ref") ?? state,
|
|
300
334
|
returnTo: safeReturnTo(url, config),
|
|
301
|
-
createdAt: Date.now()
|
|
335
|
+
createdAt: Date.now(),
|
|
336
|
+
data
|
|
302
337
|
};
|
|
303
|
-
return redirect(
|
|
304
|
-
screens.screenUrl(
|
|
305
|
-
action,
|
|
306
|
-
state,
|
|
307
|
-
session.user?.email,
|
|
308
|
-
dataParam ? JSON.parse(dataParam) : void 0
|
|
309
|
-
)
|
|
310
|
-
);
|
|
338
|
+
return redirect(screens.screenUrl(action, state, session.user?.email, data));
|
|
311
339
|
}
|
|
312
|
-
async function receiveAnswer(url, session, { config, screens }) {
|
|
340
|
+
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
313
341
|
const query = readRawQuery(url);
|
|
314
342
|
const { state, response } = await screens.readAnswer(
|
|
315
343
|
query.get("data") ?? "",
|
|
@@ -322,7 +350,12 @@ async function receiveAnswer(url, session, { config, screens }) {
|
|
|
322
350
|
delete session.screens[state];
|
|
323
351
|
let result = toScreenResult(pending.action, response);
|
|
324
352
|
try {
|
|
325
|
-
await config.onAnswer?.(result, {
|
|
353
|
+
const app = await config.onAnswer?.(result, {
|
|
354
|
+
...incoming,
|
|
355
|
+
user: session.user,
|
|
356
|
+
data: pending.data
|
|
357
|
+
});
|
|
358
|
+
if (app !== void 0) result = { ...result, app };
|
|
326
359
|
} catch (error) {
|
|
327
360
|
result = { action: pending.action, result: "FAILURE", error: error.message };
|
|
328
361
|
}
|
|
@@ -348,12 +381,14 @@ function pruneScreens(screens = {}) {
|
|
|
348
381
|
return Object.fromEntries(fresh);
|
|
349
382
|
}
|
|
350
383
|
function safeReturnTo(url, config) {
|
|
351
|
-
|
|
352
|
-
|
|
384
|
+
return safePath(url.searchParams.get("returnTo") ?? void 0, config) ?? "/";
|
|
385
|
+
}
|
|
386
|
+
function safePath(path, config) {
|
|
387
|
+
if (!path || !path.startsWith("/") || path.startsWith("//")) return void 0;
|
|
353
388
|
try {
|
|
354
|
-
return new URL(
|
|
389
|
+
return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
|
|
355
390
|
} catch {
|
|
356
|
-
return
|
|
391
|
+
return void 0;
|
|
357
392
|
}
|
|
358
393
|
}
|
|
359
394
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
@@ -362,6 +397,13 @@ var json = (body) => new Response(JSON.stringify(body), {
|
|
|
362
397
|
});
|
|
363
398
|
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
364
399
|
var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
|
|
400
|
+
function describeError(error) {
|
|
401
|
+
const messages = [];
|
|
402
|
+
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
403
|
+
if (!messages.includes(current.message)) messages.push(current.message);
|
|
404
|
+
}
|
|
405
|
+
return messages.join(": ") || String(error);
|
|
406
|
+
}
|
|
365
407
|
function errorPage(status, message) {
|
|
366
408
|
const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
367
409
|
return html(
|
|
@@ -379,6 +421,15 @@ var HubApi = class {
|
|
|
379
421
|
config;
|
|
380
422
|
appToken;
|
|
381
423
|
subscriptions;
|
|
424
|
+
// A call to Clearly.HUB. When it can't be reached at all, say where: "fetch
|
|
425
|
+
// failed" alone doesn't tell you which service isn't running
|
|
426
|
+
async call(url, init) {
|
|
427
|
+
try {
|
|
428
|
+
return await this.config.fetch(url, init);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
382
433
|
get hasAppKey() {
|
|
383
434
|
return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
|
|
384
435
|
}
|
|
@@ -391,7 +442,7 @@ var HubApi = class {
|
|
|
391
442
|
throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
|
|
392
443
|
}
|
|
393
444
|
const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
|
|
394
|
-
const response = await this.
|
|
445
|
+
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
395
446
|
method: "POST",
|
|
396
447
|
headers: {
|
|
397
448
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -414,7 +465,7 @@ var HubApi = class {
|
|
|
414
465
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
415
466
|
return this.subscriptions.value;
|
|
416
467
|
}
|
|
417
|
-
const response = await this.
|
|
468
|
+
const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
|
|
418
469
|
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
419
470
|
});
|
|
420
471
|
if (!response.ok) {
|
|
@@ -434,7 +485,7 @@ var HubApi = class {
|
|
|
434
485
|
}
|
|
435
486
|
// Report one billable use; Clearly.HUB bills the hub
|
|
436
487
|
async reportUsage(usage) {
|
|
437
|
-
const response = await this.
|
|
488
|
+
const response = await this.call(this.config.urls.usage, {
|
|
438
489
|
method: "POST",
|
|
439
490
|
headers: {
|
|
440
491
|
"Content-Type": "application/json",
|
|
@@ -641,20 +692,25 @@ function clearlyHub(options) {
|
|
|
641
692
|
async getSubscription(hubId) {
|
|
642
693
|
return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
|
|
643
694
|
},
|
|
644
|
-
// Report billable use by the signed
|
|
695
|
+
// Report billable use in a hub, by the person signed in with Clearly.HUB
|
|
696
|
+
// (request), or by a Clearly.HUB account you stored earlier (sub), for
|
|
697
|
+
// example for someone who signed in with your own login, or a nightly job
|
|
645
698
|
async reportUsage({
|
|
646
699
|
request,
|
|
700
|
+
sub,
|
|
647
701
|
hubId,
|
|
648
702
|
quantity = 1
|
|
649
703
|
}) {
|
|
650
|
-
const
|
|
651
|
-
if (!
|
|
704
|
+
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
705
|
+
if (!person) {
|
|
706
|
+
throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
|
|
707
|
+
}
|
|
652
708
|
const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
|
|
653
709
|
if (subscription?.status !== "ACTIVE") {
|
|
654
710
|
throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
|
|
655
711
|
}
|
|
656
712
|
await parts.api.reportUsage({
|
|
657
|
-
sub:
|
|
713
|
+
sub: person,
|
|
658
714
|
hubId,
|
|
659
715
|
subscriptionId: subscription.subscriptionId,
|
|
660
716
|
quantity
|
package/dist/index.d.cts
CHANGED
|
@@ -10,6 +10,7 @@ interface ScreenData {
|
|
|
10
10
|
format: string;
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
+
declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
|
|
13
14
|
interface HubRef {
|
|
14
15
|
_id: string;
|
|
15
16
|
name: string;
|
|
@@ -61,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
61
62
|
action: A;
|
|
62
63
|
result: 'SUCCESS';
|
|
63
64
|
answer: ScreenAnswers[A];
|
|
65
|
+
app?: unknown;
|
|
64
66
|
} | {
|
|
65
67
|
action: A;
|
|
66
68
|
result: 'CANCELLED' | 'FAILURE';
|
|
67
69
|
answer?: undefined;
|
|
68
70
|
error?: string;
|
|
71
|
+
app?: unknown;
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
interface SessionUser {
|
|
@@ -111,7 +114,19 @@ interface SignedInPerson {
|
|
|
111
114
|
email: string;
|
|
112
115
|
name?: string;
|
|
113
116
|
}
|
|
114
|
-
interface
|
|
117
|
+
interface HookContext<Req = unknown> {
|
|
118
|
+
request: Request;
|
|
119
|
+
req?: Req;
|
|
120
|
+
}
|
|
121
|
+
interface SignInDecision {
|
|
122
|
+
appUserId?: string;
|
|
123
|
+
redirectTo?: string;
|
|
124
|
+
}
|
|
125
|
+
interface AnswerContext<Req = unknown> extends HookContext<Req> {
|
|
126
|
+
user?: SessionUser;
|
|
127
|
+
data?: unknown;
|
|
128
|
+
}
|
|
129
|
+
interface ClearlyHubOptions<Req = unknown> {
|
|
115
130
|
clientId: string;
|
|
116
131
|
m2mClientId?: string;
|
|
117
132
|
m2mClientSecret?: string;
|
|
@@ -121,10 +136,8 @@ interface ClearlyHubOptions {
|
|
|
121
136
|
hubUrl?: string;
|
|
122
137
|
urls?: Partial<ClearlyHubUrls>;
|
|
123
138
|
signedOutUrl?: string;
|
|
124
|
-
onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
|
|
125
|
-
onAnswer?: (result: ScreenResult, context:
|
|
126
|
-
user?: SessionUser;
|
|
127
|
-
}) => Promise<void> | void;
|
|
139
|
+
onSignIn?: (person: SignedInPerson, context: HookContext<Req>) => Promise<string | SignInDecision | void> | string | SignInDecision | void;
|
|
140
|
+
onAnswer?: (result: ScreenResult, context: AnswerContext<Req>) => unknown;
|
|
128
141
|
fetch?: typeof fetch;
|
|
129
142
|
}
|
|
130
143
|
interface ClearlyHubUrls {
|
|
@@ -145,8 +158,8 @@ type CookieSource = Request | {
|
|
|
145
158
|
headers: Record<string, string | string[] | undefined>;
|
|
146
159
|
};
|
|
147
160
|
|
|
148
|
-
declare function clearlyHub(options: ClearlyHubOptions): {
|
|
149
|
-
handler: (request: Request) => Promise<Response>;
|
|
161
|
+
declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
162
|
+
handler: (request: Request, req?: unknown) => Promise<Response>;
|
|
150
163
|
express: () => (req: http.IncomingMessage & {
|
|
151
164
|
originalUrl?: string;
|
|
152
165
|
}, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
|
|
@@ -156,13 +169,14 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
156
169
|
getAppToken: () => Promise<string>;
|
|
157
170
|
getSubscriptions: () => Promise<AppSubscription[]>;
|
|
158
171
|
getSubscription(hubId: string): Promise<AppSubscription | null>;
|
|
159
|
-
reportUsage({ request, hubId, quantity, }: {
|
|
160
|
-
request
|
|
172
|
+
reportUsage({ request, sub, hubId, quantity, }: {
|
|
173
|
+
request?: CookieSource;
|
|
174
|
+
sub?: string;
|
|
161
175
|
hubId: string;
|
|
162
176
|
quantity?: number;
|
|
163
177
|
}): Promise<void>;
|
|
164
178
|
};
|
|
165
179
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
166
180
|
|
|
167
|
-
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, clearlyHub, isScreenAction };
|
|
168
|
-
export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
|
|
181
|
+
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ interface ScreenData {
|
|
|
10
10
|
format: string;
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
+
declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
|
|
13
14
|
interface HubRef {
|
|
14
15
|
_id: string;
|
|
15
16
|
name: string;
|
|
@@ -61,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
61
62
|
action: A;
|
|
62
63
|
result: 'SUCCESS';
|
|
63
64
|
answer: ScreenAnswers[A];
|
|
65
|
+
app?: unknown;
|
|
64
66
|
} | {
|
|
65
67
|
action: A;
|
|
66
68
|
result: 'CANCELLED' | 'FAILURE';
|
|
67
69
|
answer?: undefined;
|
|
68
70
|
error?: string;
|
|
71
|
+
app?: unknown;
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
interface SessionUser {
|
|
@@ -111,7 +114,19 @@ interface SignedInPerson {
|
|
|
111
114
|
email: string;
|
|
112
115
|
name?: string;
|
|
113
116
|
}
|
|
114
|
-
interface
|
|
117
|
+
interface HookContext<Req = unknown> {
|
|
118
|
+
request: Request;
|
|
119
|
+
req?: Req;
|
|
120
|
+
}
|
|
121
|
+
interface SignInDecision {
|
|
122
|
+
appUserId?: string;
|
|
123
|
+
redirectTo?: string;
|
|
124
|
+
}
|
|
125
|
+
interface AnswerContext<Req = unknown> extends HookContext<Req> {
|
|
126
|
+
user?: SessionUser;
|
|
127
|
+
data?: unknown;
|
|
128
|
+
}
|
|
129
|
+
interface ClearlyHubOptions<Req = unknown> {
|
|
115
130
|
clientId: string;
|
|
116
131
|
m2mClientId?: string;
|
|
117
132
|
m2mClientSecret?: string;
|
|
@@ -121,10 +136,8 @@ interface ClearlyHubOptions {
|
|
|
121
136
|
hubUrl?: string;
|
|
122
137
|
urls?: Partial<ClearlyHubUrls>;
|
|
123
138
|
signedOutUrl?: string;
|
|
124
|
-
onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
|
|
125
|
-
onAnswer?: (result: ScreenResult, context:
|
|
126
|
-
user?: SessionUser;
|
|
127
|
-
}) => Promise<void> | void;
|
|
139
|
+
onSignIn?: (person: SignedInPerson, context: HookContext<Req>) => Promise<string | SignInDecision | void> | string | SignInDecision | void;
|
|
140
|
+
onAnswer?: (result: ScreenResult, context: AnswerContext<Req>) => unknown;
|
|
128
141
|
fetch?: typeof fetch;
|
|
129
142
|
}
|
|
130
143
|
interface ClearlyHubUrls {
|
|
@@ -145,8 +158,8 @@ type CookieSource = Request | {
|
|
|
145
158
|
headers: Record<string, string | string[] | undefined>;
|
|
146
159
|
};
|
|
147
160
|
|
|
148
|
-
declare function clearlyHub(options: ClearlyHubOptions): {
|
|
149
|
-
handler: (request: Request) => Promise<Response>;
|
|
161
|
+
declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
162
|
+
handler: (request: Request, req?: unknown) => Promise<Response>;
|
|
150
163
|
express: () => (req: http.IncomingMessage & {
|
|
151
164
|
originalUrl?: string;
|
|
152
165
|
}, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
|
|
@@ -156,13 +169,14 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
156
169
|
getAppToken: () => Promise<string>;
|
|
157
170
|
getSubscriptions: () => Promise<AppSubscription[]>;
|
|
158
171
|
getSubscription(hubId: string): Promise<AppSubscription | null>;
|
|
159
|
-
reportUsage({ request, hubId, quantity, }: {
|
|
160
|
-
request
|
|
172
|
+
reportUsage({ request, sub, hubId, quantity, }: {
|
|
173
|
+
request?: CookieSource;
|
|
174
|
+
sub?: string;
|
|
161
175
|
hubId: string;
|
|
162
176
|
quantity?: number;
|
|
163
177
|
}): Promise<void>;
|
|
164
178
|
};
|
|
165
179
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
166
180
|
|
|
167
|
-
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, clearlyHub, isScreenAction };
|
|
168
|
-
export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
|
|
181
|
+
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 };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,23 @@ var SCREEN_ACTIONS = [
|
|
|
8
8
|
function isScreenAction(value) {
|
|
9
9
|
return SCREEN_ACTIONS.includes(value);
|
|
10
10
|
}
|
|
11
|
+
function checkScreenData(action, data) {
|
|
12
|
+
if (action !== "REGISTER_RESOURCE") return void 0;
|
|
13
|
+
const { url, format } = data ?? {};
|
|
14
|
+
if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
|
|
15
|
+
return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
|
|
16
|
+
}
|
|
17
|
+
let hostname;
|
|
18
|
+
try {
|
|
19
|
+
hostname = new URL(url).hostname;
|
|
20
|
+
} catch {
|
|
21
|
+
return `REGISTER_RESOURCE: "${url}" is not a URL`;
|
|
22
|
+
}
|
|
23
|
+
if (!hostname.includes(".")) {
|
|
24
|
+
return "REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost";
|
|
25
|
+
}
|
|
26
|
+
return void 0;
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
// ../shared/src/protocol.ts
|
|
13
30
|
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
@@ -55,7 +72,8 @@ function resolveConfig(options) {
|
|
|
55
72
|
signedOutUrl: options.signedOutUrl ?? appUrl,
|
|
56
73
|
onSignIn: options.onSignIn,
|
|
57
74
|
onAnswer: options.onAnswer,
|
|
58
|
-
|
|
75
|
+
// Looked up on every call, so a fetch wrapped later (logging, tracing) is used
|
|
76
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
59
77
|
isSecure: appUrl.startsWith("https://")
|
|
60
78
|
};
|
|
61
79
|
}
|
|
@@ -70,7 +88,7 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
70
88
|
else if (value !== void 0) headers.set(name, value);
|
|
71
89
|
}
|
|
72
90
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
73
|
-
const response = await handle(new Request(url, { method: req.method, headers }));
|
|
91
|
+
const response = await handle(new Request(url, { method: req.method, headers }), req);
|
|
74
92
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
75
93
|
next();
|
|
76
94
|
return;
|
|
@@ -170,18 +188,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
|
|
|
170
188
|
var MAX_PENDING_SCREENS = 10;
|
|
171
189
|
function createHandler(parts) {
|
|
172
190
|
const { config, sessions } = parts;
|
|
173
|
-
return async function handle(request) {
|
|
191
|
+
return async function handle(request, req) {
|
|
174
192
|
const url = new URL(request.url);
|
|
175
193
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
176
194
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
177
195
|
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
|
196
|
+
const host = request.headers.get("host");
|
|
197
|
+
const appHost = new URL(config.appUrl).host;
|
|
198
|
+
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
199
|
+
return redirect(`${config.appUrl}${url.pathname}${url.search}`);
|
|
200
|
+
}
|
|
178
201
|
const { data, chunkCount } = await sessions.read(request);
|
|
179
202
|
const before = JSON.stringify(data);
|
|
180
203
|
let response;
|
|
181
204
|
try {
|
|
182
|
-
response = await routeRequest(route, url, data, parts);
|
|
205
|
+
response = await routeRequest(route, url, data, parts, { request, req });
|
|
183
206
|
} catch (error) {
|
|
184
|
-
|
|
207
|
+
const isRefusedAnswer = error instanceof AnswerError;
|
|
208
|
+
if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
209
|
+
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
185
210
|
}
|
|
186
211
|
if (JSON.stringify(data) !== before) {
|
|
187
212
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -191,12 +216,12 @@ function createHandler(parts) {
|
|
|
191
216
|
return response;
|
|
192
217
|
};
|
|
193
218
|
}
|
|
194
|
-
async function routeRequest(route, url, session, parts) {
|
|
219
|
+
async function routeRequest(route, url, session, parts, incoming) {
|
|
195
220
|
switch (route) {
|
|
196
221
|
case ROUTES.signIn:
|
|
197
222
|
return startSignIn(url, session, parts);
|
|
198
223
|
case ROUTES.callback:
|
|
199
|
-
return finishSignIn(url, session, parts);
|
|
224
|
+
return finishSignIn(url, session, parts, incoming);
|
|
200
225
|
case ROUTES.signOut:
|
|
201
226
|
for (const key of Object.keys(session)) delete session[key];
|
|
202
227
|
return redirect(parts.signIn.signOutUrl());
|
|
@@ -205,7 +230,7 @@ async function routeRequest(route, url, session, parts) {
|
|
|
205
230
|
case ROUTES.open:
|
|
206
231
|
return openScreen(url, session, parts);
|
|
207
232
|
case ROUTES.answer:
|
|
208
|
-
return receiveAnswer(url, session, parts);
|
|
233
|
+
return receiveAnswer(url, session, parts, incoming);
|
|
209
234
|
case ROUTES.result: {
|
|
210
235
|
const result = session.result ?? null;
|
|
211
236
|
delete session.result;
|
|
@@ -220,16 +245,17 @@ async function startSignIn(url, session, { config, signIn }) {
|
|
|
220
245
|
session.signIn = pending;
|
|
221
246
|
return redirect(authorizationUrl);
|
|
222
247
|
}
|
|
223
|
-
async function finishSignIn(url, session, { config, signIn }) {
|
|
248
|
+
async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
224
249
|
const pending = session.signIn;
|
|
225
250
|
if (!pending) return redirect(config.appUrl);
|
|
226
251
|
delete session.signIn;
|
|
227
252
|
const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
|
|
228
253
|
const { person, tokens } = await signIn.finish(currentUrl, pending);
|
|
229
|
-
const
|
|
254
|
+
const decision = await config.onSignIn?.(person, incoming);
|
|
255
|
+
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
230
256
|
session.user = { ...person, appUserId };
|
|
231
257
|
session.tokens = tokens;
|
|
232
|
-
return redirect(`${config.appUrl}${pending.returnTo}`);
|
|
258
|
+
return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
|
|
233
259
|
}
|
|
234
260
|
async function readSession(session, parts) {
|
|
235
261
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
@@ -255,6 +281,14 @@ function openScreen(url, session, { config, screens }) {
|
|
|
255
281
|
if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
|
|
256
282
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
257
283
|
const dataParam = url.searchParams.get("data");
|
|
284
|
+
let data;
|
|
285
|
+
try {
|
|
286
|
+
data = dataParam ? JSON.parse(dataParam) : void 0;
|
|
287
|
+
} catch {
|
|
288
|
+
return errorPage(400, "The screen data is not valid JSON");
|
|
289
|
+
}
|
|
290
|
+
const dataProblem = checkScreenData(action, data);
|
|
291
|
+
if (dataProblem) return errorPage(400, dataProblem);
|
|
258
292
|
const state = crypto.randomUUID();
|
|
259
293
|
session.screens = pruneScreens(session.screens);
|
|
260
294
|
session.screens[state] = {
|
|
@@ -262,18 +296,12 @@ function openScreen(url, session, { config, screens }) {
|
|
|
262
296
|
display,
|
|
263
297
|
ref: url.searchParams.get("ref") ?? state,
|
|
264
298
|
returnTo: safeReturnTo(url, config),
|
|
265
|
-
createdAt: Date.now()
|
|
299
|
+
createdAt: Date.now(),
|
|
300
|
+
data
|
|
266
301
|
};
|
|
267
|
-
return redirect(
|
|
268
|
-
screens.screenUrl(
|
|
269
|
-
action,
|
|
270
|
-
state,
|
|
271
|
-
session.user?.email,
|
|
272
|
-
dataParam ? JSON.parse(dataParam) : void 0
|
|
273
|
-
)
|
|
274
|
-
);
|
|
302
|
+
return redirect(screens.screenUrl(action, state, session.user?.email, data));
|
|
275
303
|
}
|
|
276
|
-
async function receiveAnswer(url, session, { config, screens }) {
|
|
304
|
+
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
277
305
|
const query = readRawQuery(url);
|
|
278
306
|
const { state, response } = await screens.readAnswer(
|
|
279
307
|
query.get("data") ?? "",
|
|
@@ -286,7 +314,12 @@ async function receiveAnswer(url, session, { config, screens }) {
|
|
|
286
314
|
delete session.screens[state];
|
|
287
315
|
let result = toScreenResult(pending.action, response);
|
|
288
316
|
try {
|
|
289
|
-
await config.onAnswer?.(result, {
|
|
317
|
+
const app = await config.onAnswer?.(result, {
|
|
318
|
+
...incoming,
|
|
319
|
+
user: session.user,
|
|
320
|
+
data: pending.data
|
|
321
|
+
});
|
|
322
|
+
if (app !== void 0) result = { ...result, app };
|
|
290
323
|
} catch (error) {
|
|
291
324
|
result = { action: pending.action, result: "FAILURE", error: error.message };
|
|
292
325
|
}
|
|
@@ -312,12 +345,14 @@ function pruneScreens(screens = {}) {
|
|
|
312
345
|
return Object.fromEntries(fresh);
|
|
313
346
|
}
|
|
314
347
|
function safeReturnTo(url, config) {
|
|
315
|
-
|
|
316
|
-
|
|
348
|
+
return safePath(url.searchParams.get("returnTo") ?? void 0, config) ?? "/";
|
|
349
|
+
}
|
|
350
|
+
function safePath(path, config) {
|
|
351
|
+
if (!path || !path.startsWith("/") || path.startsWith("//")) return void 0;
|
|
317
352
|
try {
|
|
318
|
-
return new URL(
|
|
353
|
+
return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
|
|
319
354
|
} catch {
|
|
320
|
-
return
|
|
355
|
+
return void 0;
|
|
321
356
|
}
|
|
322
357
|
}
|
|
323
358
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
@@ -326,6 +361,13 @@ var json = (body) => new Response(JSON.stringify(body), {
|
|
|
326
361
|
});
|
|
327
362
|
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
328
363
|
var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
|
|
364
|
+
function describeError(error) {
|
|
365
|
+
const messages = [];
|
|
366
|
+
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
367
|
+
if (!messages.includes(current.message)) messages.push(current.message);
|
|
368
|
+
}
|
|
369
|
+
return messages.join(": ") || String(error);
|
|
370
|
+
}
|
|
329
371
|
function errorPage(status, message) {
|
|
330
372
|
const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
331
373
|
return html(
|
|
@@ -343,6 +385,15 @@ var HubApi = class {
|
|
|
343
385
|
config;
|
|
344
386
|
appToken;
|
|
345
387
|
subscriptions;
|
|
388
|
+
// A call to Clearly.HUB. When it can't be reached at all, say where: "fetch
|
|
389
|
+
// failed" alone doesn't tell you which service isn't running
|
|
390
|
+
async call(url, init) {
|
|
391
|
+
try {
|
|
392
|
+
return await this.config.fetch(url, init);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, { cause: error });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
346
397
|
get hasAppKey() {
|
|
347
398
|
return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
|
|
348
399
|
}
|
|
@@ -355,7 +406,7 @@ var HubApi = class {
|
|
|
355
406
|
throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
|
|
356
407
|
}
|
|
357
408
|
const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
|
|
358
|
-
const response = await this.
|
|
409
|
+
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
359
410
|
method: "POST",
|
|
360
411
|
headers: {
|
|
361
412
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -378,7 +429,7 @@ var HubApi = class {
|
|
|
378
429
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
379
430
|
return this.subscriptions.value;
|
|
380
431
|
}
|
|
381
|
-
const response = await this.
|
|
432
|
+
const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
|
|
382
433
|
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
383
434
|
});
|
|
384
435
|
if (!response.ok) {
|
|
@@ -398,7 +449,7 @@ var HubApi = class {
|
|
|
398
449
|
}
|
|
399
450
|
// Report one billable use; Clearly.HUB bills the hub
|
|
400
451
|
async reportUsage(usage) {
|
|
401
|
-
const response = await this.
|
|
452
|
+
const response = await this.call(this.config.urls.usage, {
|
|
402
453
|
method: "POST",
|
|
403
454
|
headers: {
|
|
404
455
|
"Content-Type": "application/json",
|
|
@@ -605,20 +656,25 @@ function clearlyHub(options) {
|
|
|
605
656
|
async getSubscription(hubId) {
|
|
606
657
|
return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
|
|
607
658
|
},
|
|
608
|
-
// Report billable use by the signed
|
|
659
|
+
// Report billable use in a hub, by the person signed in with Clearly.HUB
|
|
660
|
+
// (request), or by a Clearly.HUB account you stored earlier (sub), for
|
|
661
|
+
// example for someone who signed in with your own login, or a nightly job
|
|
609
662
|
async reportUsage({
|
|
610
663
|
request,
|
|
664
|
+
sub,
|
|
611
665
|
hubId,
|
|
612
666
|
quantity = 1
|
|
613
667
|
}) {
|
|
614
|
-
const
|
|
615
|
-
if (!
|
|
668
|
+
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
669
|
+
if (!person) {
|
|
670
|
+
throw new Error("Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account");
|
|
671
|
+
}
|
|
616
672
|
const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
|
|
617
673
|
if (subscription?.status !== "ACTIVE") {
|
|
618
674
|
throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
|
|
619
675
|
}
|
|
620
676
|
await parts.api.reportUsage({
|
|
621
|
-
sub:
|
|
677
|
+
sub: person,
|
|
622
678
|
hubId,
|
|
623
679
|
subscriptionId: subscription.subscriptionId,
|
|
624
680
|
quantity
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clearly-hub/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
7
|
-
"keywords": [
|
|
7
|
+
"keywords": [
|
|
8
|
+
"clearly-hub",
|
|
9
|
+
"oup",
|
|
10
|
+
"open-urban-platform",
|
|
11
|
+
"oidc",
|
|
12
|
+
"sdk"
|
|
13
|
+
],
|
|
8
14
|
"type": "module",
|
|
9
15
|
"main": "./dist/index.cjs",
|
|
10
16
|
"module": "./dist/index.js",
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
"openid-client": "^6.8.8"
|
|
34
40
|
},
|
|
35
41
|
"devDependencies": {
|
|
36
|
-
"@clearly-hub/shared": "
|
|
42
|
+
"@clearly-hub/shared": "*"
|
|
37
43
|
},
|
|
38
44
|
"engines": {
|
|
39
45
|
"node": ">=20"
|