@clearly-hub/node 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -34
- package/dist/index.cjs +71 -27
- package/dist/index.d.cts +23 -10
- package/dist/index.d.ts +23 -10
- package/dist/index.js +71 -27
- package/package.json +1 -1
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
|
|
|
@@ -55,18 +55,18 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
|
|
|
55
55
|
</script>
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
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>`.
|
|
59
59
|
|
|
60
60
|
## The browser package
|
|
61
61
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
| `clearlyHub.session()`
|
|
65
|
-
| `clearlyHub.signIn()`, `signOut()`
|
|
66
|
-
| `clearlyHub.open(action, { data, display })`
|
|
67
|
-
| `clearlyHub.result()`
|
|
68
|
-
| `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen
|
|
69
|
-
| `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 |
|
|
70
70
|
|
|
71
71
|
**Styling the button.** It uses your page's font. Attributes for the common cases:
|
|
72
72
|
`variant="outline"`, `size="small"` or `size="large"`. CSS custom properties for your look:
|
|
@@ -74,32 +74,51 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
|
|
|
74
74
|
`--clearly-hub-font`. For anything else, style the real button through `::part(button)`:
|
|
75
75
|
|
|
76
76
|
```css
|
|
77
|
-
clearly-hub-button {
|
|
78
|
-
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
|
+
}
|
|
79
84
|
```
|
|
80
85
|
|
|
81
86
|
Screens open in a popup by default (`data-display="redirect"` on the script tag changes that). A
|
|
82
87
|
blocked popup falls back to a redirect.
|
|
83
88
|
|
|
84
|
-
| Action
|
|
85
|
-
|
|
|
86
|
-
| `SELECT_SUBSCRIPTION` |
|
|
87
|
-
| `DATA_PICKER`
|
|
88
|
-
| `REGISTER_RESOURCE`
|
|
89
|
-
| `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`) |
|
|
90
95
|
|
|
91
96
|
## The Node package
|
|
92
97
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
| `hub.express()`, `hub.handler`
|
|
96
|
-
| `onSignIn(person)`
|
|
97
|
-
| `onAnswer(result, { user })`
|
|
98
|
-
| `hub.getUser(req)`
|
|
99
|
-
| `hub.fetchAsUser(req, url)`
|
|
100
|
-
| `hub.getSubscription(hubId)`, `getSubscriptions()`
|
|
101
|
-
| `hub.reportUsage({ request, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub |
|
|
102
|
-
| `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
|
+
```
|
|
103
122
|
|
|
104
123
|
**What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
|
|
105
124
|
opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
|
|
@@ -112,7 +131,6 @@ store; it's split over several cookies because tokens are large.
|
|
|
112
131
|
|
|
113
132
|
- Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
|
|
114
133
|
`onAnswer` for `SELECT_SUBSCRIPTION`.
|
|
115
|
-
- Child hubs in `session().hubs`: Clearly.HUB only returns root hubs today.
|
|
116
134
|
- Other server languages: follow the Build checklist.
|
|
117
135
|
|
|
118
136
|
## Working on this repo
|
package/dist/index.cjs
CHANGED
|
@@ -50,8 +50,14 @@ function checkScreenData(action, data) {
|
|
|
50
50
|
if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
|
|
51
51
|
return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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";
|
|
55
61
|
}
|
|
56
62
|
return void 0;
|
|
57
63
|
}
|
|
@@ -102,7 +108,8 @@ function resolveConfig(options) {
|
|
|
102
108
|
signedOutUrl: options.signedOutUrl ?? appUrl,
|
|
103
109
|
onSignIn: options.onSignIn,
|
|
104
110
|
onAnswer: options.onAnswer,
|
|
105
|
-
|
|
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)),
|
|
106
113
|
isSecure: appUrl.startsWith("https://")
|
|
107
114
|
};
|
|
108
115
|
}
|
|
@@ -117,7 +124,7 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
117
124
|
else if (value !== void 0) headers.set(name, value);
|
|
118
125
|
}
|
|
119
126
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
120
|
-
const response = await handle(new Request(url, { method: req.method, headers }));
|
|
127
|
+
const response = await handle(new Request(url, { method: req.method, headers }), req);
|
|
121
128
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
122
129
|
next();
|
|
123
130
|
return;
|
|
@@ -217,18 +224,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
|
|
|
217
224
|
var MAX_PENDING_SCREENS = 10;
|
|
218
225
|
function createHandler(parts) {
|
|
219
226
|
const { config, sessions } = parts;
|
|
220
|
-
return async function handle(request) {
|
|
227
|
+
return async function handle(request, req) {
|
|
221
228
|
const url = new URL(request.url);
|
|
222
229
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
223
230
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
224
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
|
+
}
|
|
225
237
|
const { data, chunkCount } = await sessions.read(request);
|
|
226
238
|
const before = JSON.stringify(data);
|
|
227
239
|
let response;
|
|
228
240
|
try {
|
|
229
|
-
response = await routeRequest(route, url, data, parts);
|
|
241
|
+
response = await routeRequest(route, url, data, parts, { request, req });
|
|
230
242
|
} catch (error) {
|
|
231
|
-
|
|
243
|
+
const isRefusedAnswer = error instanceof AnswerError;
|
|
244
|
+
if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
245
|
+
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
232
246
|
}
|
|
233
247
|
if (JSON.stringify(data) !== before) {
|
|
234
248
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -238,12 +252,12 @@ function createHandler(parts) {
|
|
|
238
252
|
return response;
|
|
239
253
|
};
|
|
240
254
|
}
|
|
241
|
-
async function routeRequest(route, url, session, parts) {
|
|
255
|
+
async function routeRequest(route, url, session, parts, incoming) {
|
|
242
256
|
switch (route) {
|
|
243
257
|
case ROUTES.signIn:
|
|
244
258
|
return startSignIn(url, session, parts);
|
|
245
259
|
case ROUTES.callback:
|
|
246
|
-
return finishSignIn(url, session, parts);
|
|
260
|
+
return finishSignIn(url, session, parts, incoming);
|
|
247
261
|
case ROUTES.signOut:
|
|
248
262
|
for (const key of Object.keys(session)) delete session[key];
|
|
249
263
|
return redirect(parts.signIn.signOutUrl());
|
|
@@ -252,7 +266,7 @@ async function routeRequest(route, url, session, parts) {
|
|
|
252
266
|
case ROUTES.open:
|
|
253
267
|
return openScreen(url, session, parts);
|
|
254
268
|
case ROUTES.answer:
|
|
255
|
-
return receiveAnswer(url, session, parts);
|
|
269
|
+
return receiveAnswer(url, session, parts, incoming);
|
|
256
270
|
case ROUTES.result: {
|
|
257
271
|
const result = session.result ?? null;
|
|
258
272
|
delete session.result;
|
|
@@ -267,16 +281,17 @@ async function startSignIn(url, session, { config, signIn }) {
|
|
|
267
281
|
session.signIn = pending;
|
|
268
282
|
return redirect(authorizationUrl);
|
|
269
283
|
}
|
|
270
|
-
async function finishSignIn(url, session, { config, signIn }) {
|
|
284
|
+
async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
271
285
|
const pending = session.signIn;
|
|
272
286
|
if (!pending) return redirect(config.appUrl);
|
|
273
287
|
delete session.signIn;
|
|
274
288
|
const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
|
|
275
289
|
const { person, tokens } = await signIn.finish(currentUrl, pending);
|
|
276
|
-
const
|
|
290
|
+
const decision = await config.onSignIn?.(person, incoming);
|
|
291
|
+
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
277
292
|
session.user = { ...person, appUserId };
|
|
278
293
|
session.tokens = tokens;
|
|
279
|
-
return redirect(`${config.appUrl}${pending.returnTo}`);
|
|
294
|
+
return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
|
|
280
295
|
}
|
|
281
296
|
async function readSession(session, parts) {
|
|
282
297
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
@@ -317,11 +332,12 @@ function openScreen(url, session, { config, screens }) {
|
|
|
317
332
|
display,
|
|
318
333
|
ref: url.searchParams.get("ref") ?? state,
|
|
319
334
|
returnTo: safeReturnTo(url, config),
|
|
320
|
-
createdAt: Date.now()
|
|
335
|
+
createdAt: Date.now(),
|
|
336
|
+
data
|
|
321
337
|
};
|
|
322
338
|
return redirect(screens.screenUrl(action, state, session.user?.email, data));
|
|
323
339
|
}
|
|
324
|
-
async function receiveAnswer(url, session, { config, screens }) {
|
|
340
|
+
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
325
341
|
const query = readRawQuery(url);
|
|
326
342
|
const { state, response } = await screens.readAnswer(
|
|
327
343
|
query.get("data") ?? "",
|
|
@@ -334,7 +350,12 @@ async function receiveAnswer(url, session, { config, screens }) {
|
|
|
334
350
|
delete session.screens[state];
|
|
335
351
|
let result = toScreenResult(pending.action, response);
|
|
336
352
|
try {
|
|
337
|
-
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 };
|
|
338
359
|
} catch (error) {
|
|
339
360
|
result = { action: pending.action, result: "FAILURE", error: error.message };
|
|
340
361
|
}
|
|
@@ -360,12 +381,14 @@ function pruneScreens(screens = {}) {
|
|
|
360
381
|
return Object.fromEntries(fresh);
|
|
361
382
|
}
|
|
362
383
|
function safeReturnTo(url, config) {
|
|
363
|
-
|
|
364
|
-
|
|
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;
|
|
365
388
|
try {
|
|
366
|
-
return new URL(
|
|
389
|
+
return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
|
|
367
390
|
} catch {
|
|
368
|
-
return
|
|
391
|
+
return void 0;
|
|
369
392
|
}
|
|
370
393
|
}
|
|
371
394
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
@@ -374,6 +397,13 @@ var json = (body) => new Response(JSON.stringify(body), {
|
|
|
374
397
|
});
|
|
375
398
|
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
376
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
|
+
}
|
|
377
407
|
function errorPage(status, message) {
|
|
378
408
|
const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
379
409
|
return html(
|
|
@@ -391,6 +421,15 @@ var HubApi = class {
|
|
|
391
421
|
config;
|
|
392
422
|
appToken;
|
|
393
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
|
+
}
|
|
394
433
|
get hasAppKey() {
|
|
395
434
|
return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
|
|
396
435
|
}
|
|
@@ -403,7 +442,7 @@ var HubApi = class {
|
|
|
403
442
|
throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
|
|
404
443
|
}
|
|
405
444
|
const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
|
|
406
|
-
const response = await this.
|
|
445
|
+
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
407
446
|
method: "POST",
|
|
408
447
|
headers: {
|
|
409
448
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -426,7 +465,7 @@ var HubApi = class {
|
|
|
426
465
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
427
466
|
return this.subscriptions.value;
|
|
428
467
|
}
|
|
429
|
-
const response = await this.
|
|
468
|
+
const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
|
|
430
469
|
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
431
470
|
});
|
|
432
471
|
if (!response.ok) {
|
|
@@ -446,7 +485,7 @@ var HubApi = class {
|
|
|
446
485
|
}
|
|
447
486
|
// Report one billable use; Clearly.HUB bills the hub
|
|
448
487
|
async reportUsage(usage) {
|
|
449
|
-
const response = await this.
|
|
488
|
+
const response = await this.call(this.config.urls.usage, {
|
|
450
489
|
method: "POST",
|
|
451
490
|
headers: {
|
|
452
491
|
"Content-Type": "application/json",
|
|
@@ -653,20 +692,25 @@ function clearlyHub(options) {
|
|
|
653
692
|
async getSubscription(hubId) {
|
|
654
693
|
return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
|
|
655
694
|
},
|
|
656
|
-
// 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
|
|
657
698
|
async reportUsage({
|
|
658
699
|
request,
|
|
700
|
+
sub,
|
|
659
701
|
hubId,
|
|
660
702
|
quantity = 1
|
|
661
703
|
}) {
|
|
662
|
-
const
|
|
663
|
-
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
|
+
}
|
|
664
708
|
const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
|
|
665
709
|
if (subscription?.status !== "ACTIVE") {
|
|
666
710
|
throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
|
|
667
711
|
}
|
|
668
712
|
await parts.api.reportUsage({
|
|
669
|
-
sub:
|
|
713
|
+
sub: person,
|
|
670
714
|
hubId,
|
|
671
715
|
subscriptionId: subscription.subscriptionId,
|
|
672
716
|
quantity
|
package/dist/index.d.cts
CHANGED
|
@@ -62,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
62
62
|
action: A;
|
|
63
63
|
result: 'SUCCESS';
|
|
64
64
|
answer: ScreenAnswers[A];
|
|
65
|
+
app?: unknown;
|
|
65
66
|
} | {
|
|
66
67
|
action: A;
|
|
67
68
|
result: 'CANCELLED' | 'FAILURE';
|
|
68
69
|
answer?: undefined;
|
|
69
70
|
error?: string;
|
|
71
|
+
app?: unknown;
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
interface SessionUser {
|
|
@@ -112,7 +114,19 @@ interface SignedInPerson {
|
|
|
112
114
|
email: string;
|
|
113
115
|
name?: string;
|
|
114
116
|
}
|
|
115
|
-
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> {
|
|
116
130
|
clientId: string;
|
|
117
131
|
m2mClientId?: string;
|
|
118
132
|
m2mClientSecret?: string;
|
|
@@ -122,10 +136,8 @@ interface ClearlyHubOptions {
|
|
|
122
136
|
hubUrl?: string;
|
|
123
137
|
urls?: Partial<ClearlyHubUrls>;
|
|
124
138
|
signedOutUrl?: string;
|
|
125
|
-
onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
|
|
126
|
-
onAnswer?: (result: ScreenResult, context:
|
|
127
|
-
user?: SessionUser;
|
|
128
|
-
}) => 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;
|
|
129
141
|
fetch?: typeof fetch;
|
|
130
142
|
}
|
|
131
143
|
interface ClearlyHubUrls {
|
|
@@ -146,8 +158,8 @@ type CookieSource = Request | {
|
|
|
146
158
|
headers: Record<string, string | string[] | undefined>;
|
|
147
159
|
};
|
|
148
160
|
|
|
149
|
-
declare function clearlyHub(options: ClearlyHubOptions): {
|
|
150
|
-
handler: (request: Request) => Promise<Response>;
|
|
161
|
+
declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
162
|
+
handler: (request: Request, req?: unknown) => Promise<Response>;
|
|
151
163
|
express: () => (req: http.IncomingMessage & {
|
|
152
164
|
originalUrl?: string;
|
|
153
165
|
}, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
|
|
@@ -157,8 +169,9 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
157
169
|
getAppToken: () => Promise<string>;
|
|
158
170
|
getSubscriptions: () => Promise<AppSubscription[]>;
|
|
159
171
|
getSubscription(hubId: string): Promise<AppSubscription | null>;
|
|
160
|
-
reportUsage({ request, hubId, quantity, }: {
|
|
161
|
-
request
|
|
172
|
+
reportUsage({ request, sub, hubId, quantity, }: {
|
|
173
|
+
request?: CookieSource;
|
|
174
|
+
sub?: string;
|
|
162
175
|
hubId: string;
|
|
163
176
|
quantity?: number;
|
|
164
177
|
}): Promise<void>;
|
|
@@ -166,4 +179,4 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
166
179
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
167
180
|
|
|
168
181
|
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
|
|
169
|
-
export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
|
|
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
|
@@ -62,11 +62,13 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
62
62
|
action: A;
|
|
63
63
|
result: 'SUCCESS';
|
|
64
64
|
answer: ScreenAnswers[A];
|
|
65
|
+
app?: unknown;
|
|
65
66
|
} | {
|
|
66
67
|
action: A;
|
|
67
68
|
result: 'CANCELLED' | 'FAILURE';
|
|
68
69
|
answer?: undefined;
|
|
69
70
|
error?: string;
|
|
71
|
+
app?: unknown;
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
interface SessionUser {
|
|
@@ -112,7 +114,19 @@ interface SignedInPerson {
|
|
|
112
114
|
email: string;
|
|
113
115
|
name?: string;
|
|
114
116
|
}
|
|
115
|
-
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> {
|
|
116
130
|
clientId: string;
|
|
117
131
|
m2mClientId?: string;
|
|
118
132
|
m2mClientSecret?: string;
|
|
@@ -122,10 +136,8 @@ interface ClearlyHubOptions {
|
|
|
122
136
|
hubUrl?: string;
|
|
123
137
|
urls?: Partial<ClearlyHubUrls>;
|
|
124
138
|
signedOutUrl?: string;
|
|
125
|
-
onSignIn?: (person: SignedInPerson) => Promise<string | void> | string | void;
|
|
126
|
-
onAnswer?: (result: ScreenResult, context:
|
|
127
|
-
user?: SessionUser;
|
|
128
|
-
}) => 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;
|
|
129
141
|
fetch?: typeof fetch;
|
|
130
142
|
}
|
|
131
143
|
interface ClearlyHubUrls {
|
|
@@ -146,8 +158,8 @@ type CookieSource = Request | {
|
|
|
146
158
|
headers: Record<string, string | string[] | undefined>;
|
|
147
159
|
};
|
|
148
160
|
|
|
149
|
-
declare function clearlyHub(options: ClearlyHubOptions): {
|
|
150
|
-
handler: (request: Request) => Promise<Response>;
|
|
161
|
+
declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
162
|
+
handler: (request: Request, req?: unknown) => Promise<Response>;
|
|
151
163
|
express: () => (req: http.IncomingMessage & {
|
|
152
164
|
originalUrl?: string;
|
|
153
165
|
}, res: http.ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
|
|
@@ -157,8 +169,9 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
157
169
|
getAppToken: () => Promise<string>;
|
|
158
170
|
getSubscriptions: () => Promise<AppSubscription[]>;
|
|
159
171
|
getSubscription(hubId: string): Promise<AppSubscription | null>;
|
|
160
|
-
reportUsage({ request, hubId, quantity, }: {
|
|
161
|
-
request
|
|
172
|
+
reportUsage({ request, sub, hubId, quantity, }: {
|
|
173
|
+
request?: CookieSource;
|
|
174
|
+
sub?: string;
|
|
162
175
|
hubId: string;
|
|
163
176
|
quantity?: number;
|
|
164
177
|
}): Promise<void>;
|
|
@@ -166,4 +179,4 @@ declare function clearlyHub(options: ClearlyHubOptions): {
|
|
|
166
179
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
167
180
|
|
|
168
181
|
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
|
|
169
|
-
export type { AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignedInPerson };
|
|
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
|
@@ -14,8 +14,14 @@ function checkScreenData(action, data) {
|
|
|
14
14
|
if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
|
|
15
15
|
return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
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";
|
|
19
25
|
}
|
|
20
26
|
return void 0;
|
|
21
27
|
}
|
|
@@ -66,7 +72,8 @@ function resolveConfig(options) {
|
|
|
66
72
|
signedOutUrl: options.signedOutUrl ?? appUrl,
|
|
67
73
|
onSignIn: options.onSignIn,
|
|
68
74
|
onAnswer: options.onAnswer,
|
|
69
|
-
|
|
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)),
|
|
70
77
|
isSecure: appUrl.startsWith("https://")
|
|
71
78
|
};
|
|
72
79
|
}
|
|
@@ -81,7 +88,7 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
81
88
|
else if (value !== void 0) headers.set(name, value);
|
|
82
89
|
}
|
|
83
90
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
84
|
-
const response = await handle(new Request(url, { method: req.method, headers }));
|
|
91
|
+
const response = await handle(new Request(url, { method: req.method, headers }), req);
|
|
85
92
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
86
93
|
next();
|
|
87
94
|
return;
|
|
@@ -181,18 +188,25 @@ var SCREEN_TTL_MS = 30 * 60 * 1e3;
|
|
|
181
188
|
var MAX_PENDING_SCREENS = 10;
|
|
182
189
|
function createHandler(parts) {
|
|
183
190
|
const { config, sessions } = parts;
|
|
184
|
-
return async function handle(request) {
|
|
191
|
+
return async function handle(request, req) {
|
|
185
192
|
const url = new URL(request.url);
|
|
186
193
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
187
194
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
188
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
|
+
}
|
|
189
201
|
const { data, chunkCount } = await sessions.read(request);
|
|
190
202
|
const before = JSON.stringify(data);
|
|
191
203
|
let response;
|
|
192
204
|
try {
|
|
193
|
-
response = await routeRequest(route, url, data, parts);
|
|
205
|
+
response = await routeRequest(route, url, data, parts, { request, req });
|
|
194
206
|
} catch (error) {
|
|
195
|
-
|
|
207
|
+
const isRefusedAnswer = error instanceof AnswerError;
|
|
208
|
+
if (!isRefusedAnswer) console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
209
|
+
response = errorPage(isRefusedAnswer ? 400 : 500, describeError(error));
|
|
196
210
|
}
|
|
197
211
|
if (JSON.stringify(data) !== before) {
|
|
198
212
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -202,12 +216,12 @@ function createHandler(parts) {
|
|
|
202
216
|
return response;
|
|
203
217
|
};
|
|
204
218
|
}
|
|
205
|
-
async function routeRequest(route, url, session, parts) {
|
|
219
|
+
async function routeRequest(route, url, session, parts, incoming) {
|
|
206
220
|
switch (route) {
|
|
207
221
|
case ROUTES.signIn:
|
|
208
222
|
return startSignIn(url, session, parts);
|
|
209
223
|
case ROUTES.callback:
|
|
210
|
-
return finishSignIn(url, session, parts);
|
|
224
|
+
return finishSignIn(url, session, parts, incoming);
|
|
211
225
|
case ROUTES.signOut:
|
|
212
226
|
for (const key of Object.keys(session)) delete session[key];
|
|
213
227
|
return redirect(parts.signIn.signOutUrl());
|
|
@@ -216,7 +230,7 @@ async function routeRequest(route, url, session, parts) {
|
|
|
216
230
|
case ROUTES.open:
|
|
217
231
|
return openScreen(url, session, parts);
|
|
218
232
|
case ROUTES.answer:
|
|
219
|
-
return receiveAnswer(url, session, parts);
|
|
233
|
+
return receiveAnswer(url, session, parts, incoming);
|
|
220
234
|
case ROUTES.result: {
|
|
221
235
|
const result = session.result ?? null;
|
|
222
236
|
delete session.result;
|
|
@@ -231,16 +245,17 @@ async function startSignIn(url, session, { config, signIn }) {
|
|
|
231
245
|
session.signIn = pending;
|
|
232
246
|
return redirect(authorizationUrl);
|
|
233
247
|
}
|
|
234
|
-
async function finishSignIn(url, session, { config, signIn }) {
|
|
248
|
+
async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
235
249
|
const pending = session.signIn;
|
|
236
250
|
if (!pending) return redirect(config.appUrl);
|
|
237
251
|
delete session.signIn;
|
|
238
252
|
const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
|
|
239
253
|
const { person, tokens } = await signIn.finish(currentUrl, pending);
|
|
240
|
-
const
|
|
254
|
+
const decision = await config.onSignIn?.(person, incoming);
|
|
255
|
+
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
241
256
|
session.user = { ...person, appUserId };
|
|
242
257
|
session.tokens = tokens;
|
|
243
|
-
return redirect(`${config.appUrl}${pending.returnTo}`);
|
|
258
|
+
return redirect(`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`);
|
|
244
259
|
}
|
|
245
260
|
async function readSession(session, parts) {
|
|
246
261
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
@@ -281,11 +296,12 @@ function openScreen(url, session, { config, screens }) {
|
|
|
281
296
|
display,
|
|
282
297
|
ref: url.searchParams.get("ref") ?? state,
|
|
283
298
|
returnTo: safeReturnTo(url, config),
|
|
284
|
-
createdAt: Date.now()
|
|
299
|
+
createdAt: Date.now(),
|
|
300
|
+
data
|
|
285
301
|
};
|
|
286
302
|
return redirect(screens.screenUrl(action, state, session.user?.email, data));
|
|
287
303
|
}
|
|
288
|
-
async function receiveAnswer(url, session, { config, screens }) {
|
|
304
|
+
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
289
305
|
const query = readRawQuery(url);
|
|
290
306
|
const { state, response } = await screens.readAnswer(
|
|
291
307
|
query.get("data") ?? "",
|
|
@@ -298,7 +314,12 @@ async function receiveAnswer(url, session, { config, screens }) {
|
|
|
298
314
|
delete session.screens[state];
|
|
299
315
|
let result = toScreenResult(pending.action, response);
|
|
300
316
|
try {
|
|
301
|
-
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 };
|
|
302
323
|
} catch (error) {
|
|
303
324
|
result = { action: pending.action, result: "FAILURE", error: error.message };
|
|
304
325
|
}
|
|
@@ -324,12 +345,14 @@ function pruneScreens(screens = {}) {
|
|
|
324
345
|
return Object.fromEntries(fresh);
|
|
325
346
|
}
|
|
326
347
|
function safeReturnTo(url, config) {
|
|
327
|
-
|
|
328
|
-
|
|
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;
|
|
329
352
|
try {
|
|
330
|
-
return new URL(
|
|
353
|
+
return new URL(path, config.appUrl).origin === new URL(config.appUrl).origin ? path : void 0;
|
|
331
354
|
} catch {
|
|
332
|
-
return
|
|
355
|
+
return void 0;
|
|
333
356
|
}
|
|
334
357
|
}
|
|
335
358
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
@@ -338,6 +361,13 @@ var json = (body) => new Response(JSON.stringify(body), {
|
|
|
338
361
|
});
|
|
339
362
|
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
340
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
|
+
}
|
|
341
371
|
function errorPage(status, message) {
|
|
342
372
|
const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
343
373
|
return html(
|
|
@@ -355,6 +385,15 @@ var HubApi = class {
|
|
|
355
385
|
config;
|
|
356
386
|
appToken;
|
|
357
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
|
+
}
|
|
358
397
|
get hasAppKey() {
|
|
359
398
|
return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
|
|
360
399
|
}
|
|
@@ -367,7 +406,7 @@ var HubApi = class {
|
|
|
367
406
|
throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
|
|
368
407
|
}
|
|
369
408
|
const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
|
|
370
|
-
const response = await this.
|
|
409
|
+
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
371
410
|
method: "POST",
|
|
372
411
|
headers: {
|
|
373
412
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -390,7 +429,7 @@ var HubApi = class {
|
|
|
390
429
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
391
430
|
return this.subscriptions.value;
|
|
392
431
|
}
|
|
393
|
-
const response = await this.
|
|
432
|
+
const response = await this.call(`${this.config.urls.api}/api/app-api/subscriptions`, {
|
|
394
433
|
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
395
434
|
});
|
|
396
435
|
if (!response.ok) {
|
|
@@ -410,7 +449,7 @@ var HubApi = class {
|
|
|
410
449
|
}
|
|
411
450
|
// Report one billable use; Clearly.HUB bills the hub
|
|
412
451
|
async reportUsage(usage) {
|
|
413
|
-
const response = await this.
|
|
452
|
+
const response = await this.call(this.config.urls.usage, {
|
|
414
453
|
method: "POST",
|
|
415
454
|
headers: {
|
|
416
455
|
"Content-Type": "application/json",
|
|
@@ -617,20 +656,25 @@ function clearlyHub(options) {
|
|
|
617
656
|
async getSubscription(hubId) {
|
|
618
657
|
return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
|
|
619
658
|
},
|
|
620
|
-
// 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
|
|
621
662
|
async reportUsage({
|
|
622
663
|
request,
|
|
664
|
+
sub,
|
|
623
665
|
hubId,
|
|
624
666
|
quantity = 1
|
|
625
667
|
}) {
|
|
626
|
-
const
|
|
627
|
-
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
|
+
}
|
|
628
672
|
const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
|
|
629
673
|
if (subscription?.status !== "ACTIVE") {
|
|
630
674
|
throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
|
|
631
675
|
}
|
|
632
676
|
await parts.api.reportUsage({
|
|
633
|
-
sub:
|
|
677
|
+
sub: person,
|
|
634
678
|
hubId,
|
|
635
679
|
subscriptionId: subscription.subscriptionId,
|
|
636
680
|
quantity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clearly-hub/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Clearly.HUB for Node servers: sign-in, hosted screens, subscriptions and usage in one handler",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Future Insight Group BV",
|