@clearly-hub/node 0.0.0-stage → 0.1.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/dist/index.cjs ADDED
@@ -0,0 +1,668 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ clearlyHub: () => clearlyHub
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // ../shared/src/screens.ts
38
+ var SCREEN_ACTIONS = [
39
+ "SELECT_SUBSCRIPTION",
40
+ "DATA_PICKER",
41
+ "REGISTER_RESOURCE",
42
+ "SELECT_HUB"
43
+ ];
44
+ function isScreenAction(value) {
45
+ return SCREEN_ACTIONS.includes(value);
46
+ }
47
+
48
+ // ../shared/src/protocol.ts
49
+ var DEFAULT_BASE_PATH = "/clearly-hub";
50
+ var DEFAULT_HUB_URL = "https://hub.clearly.app";
51
+ var DEFAULT_AUTH_URL = "https://auth.clearly.app";
52
+ var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
53
+ var ROUTES = {
54
+ signIn: "/sign-in",
55
+ callback: "/callback",
56
+ signOut: "/sign-out",
57
+ session: "/session",
58
+ open: "/open",
59
+ answer: "/answer",
60
+ result: "/result"
61
+ };
62
+ var ANSWER_CHANNEL = "clearly-hub-answers";
63
+
64
+ // src/config.ts
65
+ var trimSlash = (url) => url.replace(/\/+$/, "");
66
+ function resolveConfig(options) {
67
+ if (!options.clientId) throw new Error("Clearly.HUB: clientId is required");
68
+ if (!options.appUrl) throw new Error("Clearly.HUB: appUrl is required");
69
+ if (!options.secret || options.secret.length < 32) {
70
+ throw new Error("Clearly.HUB: secret must be at least 32 characters");
71
+ }
72
+ const appUrl = trimSlash(options.appUrl);
73
+ const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
74
+ const api = trimSlash(options.urls?.api ?? hubUrl);
75
+ return {
76
+ clientId: options.clientId,
77
+ m2mClientId: options.m2mClientId,
78
+ m2mClientSecret: options.m2mClientSecret,
79
+ appUrl,
80
+ secret: options.secret,
81
+ basePath: trimSlash(options.basePath ?? DEFAULT_BASE_PATH),
82
+ hubUrl,
83
+ urls: {
84
+ screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
85
+ api,
86
+ usage: options.urls?.usage ?? `${hubUrl}/register-usage`,
87
+ publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
88
+ auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
89
+ issuer: options.urls?.issuer ?? DEFAULT_ISSUER
90
+ },
91
+ signedOutUrl: options.signedOutUrl ?? appUrl,
92
+ onSignIn: options.onSignIn,
93
+ onAnswer: options.onAnswer,
94
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
95
+ isSecure: appUrl.startsWith("https://")
96
+ };
97
+ }
98
+
99
+ // src/express.ts
100
+ function toNodeMiddleware(handle, appUrl) {
101
+ return async (req, res, next) => {
102
+ try {
103
+ const headers = new Headers();
104
+ for (const [name, value] of Object.entries(req.headers)) {
105
+ if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
106
+ else if (value !== void 0) headers.set(name, value);
107
+ }
108
+ const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
109
+ const response = await handle(new Request(url, { method: req.method, headers }));
110
+ if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
111
+ next();
112
+ return;
113
+ }
114
+ res.statusCode = response.status;
115
+ response.headers.forEach((value, name) => {
116
+ if (name !== "set-cookie") res.setHeader(name, value);
117
+ });
118
+ const cookies = response.headers.getSetCookie();
119
+ if (cookies.length) res.setHeader("Set-Cookie", cookies);
120
+ res.end(Buffer.from(await response.arrayBuffer()));
121
+ } catch (error) {
122
+ if (next) next(error);
123
+ else {
124
+ res.statusCode = 500;
125
+ res.end("Internal error");
126
+ }
127
+ }
128
+ };
129
+ }
130
+
131
+ // src/screens.ts
132
+ var Screens = class {
133
+ constructor(config) {
134
+ this.config = config;
135
+ }
136
+ config;
137
+ publicKey;
138
+ get answerUrl() {
139
+ return `${this.config.appUrl}${this.config.basePath}/answer`;
140
+ }
141
+ screenUrl(action, state, email, data) {
142
+ const request = {
143
+ actions: [action],
144
+ client_id: this.config.clientId,
145
+ // Must be one of your sign-in client's callback URLs, exactly
146
+ redirect_url: this.answerUrl,
147
+ state,
148
+ // Clearly.HUB warns when someone else is signed in there
149
+ email,
150
+ data
151
+ };
152
+ return `${this.config.urls.screens}/${base64Encode(JSON.stringify(request))}`;
153
+ }
154
+ // Checks the signature and returns the answer. Ed25519, over the base64 text
155
+ // of `data` as it arrived.
156
+ async readAnswer(data, signature) {
157
+ const isSigned = await crypto.subtle.verify(
158
+ "Ed25519",
159
+ await this.getPublicKey(),
160
+ base64Decode(signature),
161
+ new TextEncoder().encode(data)
162
+ );
163
+ if (!isSigned) throw new AnswerError("This answer was not signed by Clearly.HUB");
164
+ const { request, response } = JSON.parse(new TextDecoder().decode(base64Decode(data)));
165
+ return { state: request.state, response };
166
+ }
167
+ // Fetched once; a key change needs a restart
168
+ getPublicKey() {
169
+ this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
170
+ if (!response.ok) throw new Error(`No public key at ${this.config.urls.publicKey}`);
171
+ return response.text();
172
+ }).then((pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, ["verify"])).catch((error) => {
173
+ this.publicKey = void 0;
174
+ throw error;
175
+ });
176
+ return this.publicKey;
177
+ }
178
+ };
179
+ var AnswerError = class extends Error {
180
+ };
181
+ function toScreenResult(action, response) {
182
+ const { result, ...answer } = response;
183
+ if (result === "SUCCESS") return { action, result: "SUCCESS", answer };
184
+ return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
185
+ }
186
+ function base64Encode(text) {
187
+ return btoa(String.fromCharCode(...new TextEncoder().encode(text)));
188
+ }
189
+ function base64Decode(base64) {
190
+ return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
191
+ }
192
+ function pemToDer(pem) {
193
+ return base64Decode(pem.replace(/-----[A-Z ]+-----/g, "").replace(/\s/g, ""));
194
+ }
195
+ function readRawQuery(url) {
196
+ const params = /* @__PURE__ */ new Map();
197
+ for (const pair of url.search.slice(1).split("&")) {
198
+ const index = pair.indexOf("=");
199
+ if (index > 0) params.set(pair.slice(0, index), decodeURIComponent(pair.slice(index + 1)));
200
+ }
201
+ return params;
202
+ }
203
+
204
+ // src/handler.ts
205
+ var SCREEN_TTL_MS = 30 * 60 * 1e3;
206
+ var MAX_PENDING_SCREENS = 10;
207
+ function createHandler(parts) {
208
+ const { config, sessions } = parts;
209
+ return async function handle(request) {
210
+ const url = new URL(request.url);
211
+ if (!url.pathname.startsWith(config.basePath)) return notFound();
212
+ const route = url.pathname.slice(config.basePath.length) || "/";
213
+ if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
214
+ const { data, chunkCount } = await sessions.read(request);
215
+ const before = JSON.stringify(data);
216
+ let response;
217
+ try {
218
+ response = await routeRequest(route, url, data, parts);
219
+ } catch (error) {
220
+ response = errorPage(error instanceof AnswerError ? 400 : 500, error.message);
221
+ }
222
+ if (JSON.stringify(data) !== before) {
223
+ for (const cookie of await sessions.write(data, chunkCount)) {
224
+ response.headers.append("Set-Cookie", cookie);
225
+ }
226
+ }
227
+ return response;
228
+ };
229
+ }
230
+ async function routeRequest(route, url, session, parts) {
231
+ switch (route) {
232
+ case ROUTES.signIn:
233
+ return startSignIn(url, session, parts);
234
+ case ROUTES.callback:
235
+ return finishSignIn(url, session, parts);
236
+ case ROUTES.signOut:
237
+ for (const key of Object.keys(session)) delete session[key];
238
+ return redirect(parts.signIn.signOutUrl());
239
+ case ROUTES.session:
240
+ return json(await readSession(session, parts));
241
+ case ROUTES.open:
242
+ return openScreen(url, session, parts);
243
+ case ROUTES.answer:
244
+ return receiveAnswer(url, session, parts);
245
+ case ROUTES.result: {
246
+ const result = session.result ?? null;
247
+ delete session.result;
248
+ return json(result);
249
+ }
250
+ default:
251
+ return notFound();
252
+ }
253
+ }
254
+ async function startSignIn(url, session, { config, signIn }) {
255
+ const { url: authorizationUrl, pending } = await signIn.start(safeReturnTo(url, config));
256
+ session.signIn = pending;
257
+ return redirect(authorizationUrl);
258
+ }
259
+ async function finishSignIn(url, session, { config, signIn }) {
260
+ const pending = session.signIn;
261
+ if (!pending) return redirect(config.appUrl);
262
+ delete session.signIn;
263
+ const currentUrl = new URL(`${signIn.callbackUrl}${url.search}`);
264
+ const { person, tokens } = await signIn.finish(currentUrl, pending);
265
+ const appUserId = await config.onSignIn?.(person) ?? void 0;
266
+ session.user = { ...person, appUserId };
267
+ session.tokens = tokens;
268
+ return redirect(`${config.appUrl}${pending.returnTo}`);
269
+ }
270
+ async function readSession(session, parts) {
271
+ const accessToken = await getFreshAccessToken(session, parts);
272
+ if (!session.user || !accessToken) return { signedIn: false };
273
+ return { signedIn: true, user: session.user };
274
+ }
275
+ async function getFreshAccessToken(session, { signIn }) {
276
+ const tokens = session.tokens;
277
+ if (!tokens) return void 0;
278
+ if (tokens.expiresAt > Date.now() / 1e3 + 60) return tokens.accessToken;
279
+ if (!tokens.refreshToken) return void 0;
280
+ try {
281
+ session.tokens = await signIn.refresh(tokens.refreshToken);
282
+ return session.tokens.accessToken;
283
+ } catch {
284
+ delete session.tokens;
285
+ delete session.user;
286
+ return void 0;
287
+ }
288
+ }
289
+ function openScreen(url, session, { config, screens }) {
290
+ const action = url.searchParams.get("action");
291
+ if (!isScreenAction(action)) return errorPage(400, `Unknown screen: ${action}`);
292
+ const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
293
+ const dataParam = url.searchParams.get("data");
294
+ const state = crypto.randomUUID();
295
+ session.screens = pruneScreens(session.screens);
296
+ session.screens[state] = {
297
+ action,
298
+ display,
299
+ ref: url.searchParams.get("ref") ?? state,
300
+ returnTo: safeReturnTo(url, config),
301
+ createdAt: Date.now()
302
+ };
303
+ return redirect(
304
+ screens.screenUrl(
305
+ action,
306
+ state,
307
+ session.user?.email,
308
+ dataParam ? JSON.parse(dataParam) : void 0
309
+ )
310
+ );
311
+ }
312
+ async function receiveAnswer(url, session, { config, screens }) {
313
+ const query = readRawQuery(url);
314
+ const { state, response } = await screens.readAnswer(
315
+ query.get("data") ?? "",
316
+ query.get("signature") ?? ""
317
+ );
318
+ const pending = state ? session.screens?.[state] : void 0;
319
+ if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
320
+ throw new AnswerError("This answer does not belong to a screen opened in this browser");
321
+ }
322
+ delete session.screens[state];
323
+ let result = toScreenResult(pending.action, response);
324
+ try {
325
+ await config.onAnswer?.(result, { user: session.user });
326
+ } catch (error) {
327
+ result = { action: pending.action, result: "FAILURE", error: error.message };
328
+ }
329
+ if (pending.display === "redirect") {
330
+ session.result = { ref: pending.ref, result };
331
+ return redirect(`${config.appUrl}${pending.returnTo}`);
332
+ }
333
+ return popupHandOver({ ref: pending.ref, result });
334
+ }
335
+ function popupHandOver(message) {
336
+ const payload = JSON.stringify(message).replace(/</g, "\\u003c");
337
+ return html(`<!doctype html>
338
+ <meta charset="utf-8">
339
+ <title>Clearly.HUB</title>
340
+ <p>Done. You can close this window.</p>
341
+ <script>
342
+ new BroadcastChannel(${JSON.stringify(ANSWER_CHANNEL)}).postMessage(${payload});
343
+ window.close();
344
+ </script>`);
345
+ }
346
+ function pruneScreens(screens = {}) {
347
+ const fresh = Object.entries(screens).filter(([, screen]) => Date.now() - screen.createdAt < SCREEN_TTL_MS).slice(-(MAX_PENDING_SCREENS - 1));
348
+ return Object.fromEntries(fresh);
349
+ }
350
+ function safeReturnTo(url, config) {
351
+ const returnTo = url.searchParams.get("returnTo") ?? "/";
352
+ if (!returnTo.startsWith("/") || returnTo.startsWith("//")) return "/";
353
+ try {
354
+ return new URL(returnTo, config.appUrl).origin === new URL(config.appUrl).origin ? returnTo : "/";
355
+ } catch {
356
+ return "/";
357
+ }
358
+ }
359
+ var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
360
+ var json = (body) => new Response(JSON.stringify(body), {
361
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
362
+ });
363
+ var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
364
+ var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
365
+ function errorPage(status, message) {
366
+ const escaped = message.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
367
+ return html(
368
+ `<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
369
+ status
370
+ );
371
+ }
372
+
373
+ // src/hubApi.ts
374
+ var SUBSCRIPTIONS_CACHE_MS = 3e4;
375
+ var HubApi = class {
376
+ constructor(config) {
377
+ this.config = config;
378
+ }
379
+ config;
380
+ appToken;
381
+ subscriptions;
382
+ get hasAppKey() {
383
+ return Boolean(this.config.m2mClientId && this.config.m2mClientSecret);
384
+ }
385
+ // Your app's own key: OAuth client credentials for its M2M client, cached
386
+ // until shortly before it expires
387
+ async getAppToken() {
388
+ const now = Date.now() / 1e3;
389
+ if (this.appToken && this.appToken.expiresAt > now + 60) return this.appToken.value;
390
+ if (!this.hasAppKey) {
391
+ throw new Error("Clearly.HUB: m2mClientId and m2mClientSecret are needed for this");
392
+ }
393
+ const credentials = btoa(`${this.config.m2mClientId}:${this.config.m2mClientSecret}`);
394
+ const response = await this.config.fetch(`${this.config.urls.auth}/oauth2/token`, {
395
+ method: "POST",
396
+ headers: {
397
+ "Content-Type": "application/x-www-form-urlencoded",
398
+ Authorization: `Basic ${credentials}`
399
+ },
400
+ body: new URLSearchParams({
401
+ grant_type: "client_credentials",
402
+ scope: "CallbackScopes/data.usage"
403
+ })
404
+ });
405
+ if (!response.ok) {
406
+ throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
407
+ }
408
+ const body = await response.json();
409
+ this.appToken = { value: body.access_token, expiresAt: now + body.expires_in };
410
+ return body.access_token;
411
+ }
412
+ // Every hub that bought your app, with status and your plan id
413
+ async getSubscriptions() {
414
+ if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
415
+ return this.subscriptions.value;
416
+ }
417
+ const response = await this.config.fetch(`${this.config.urls.api}/api/app-api/subscriptions`, {
418
+ headers: { Authorization: `Bearer ${await this.getAppToken()}` }
419
+ });
420
+ if (!response.ok) {
421
+ throw new Error(`Could not read the subscriptions from Clearly.HUB (${response.status})`);
422
+ }
423
+ const items = await response.json();
424
+ const value = items.map((item) => ({
425
+ subscriptionId: item.subscriptionId,
426
+ status: item.status,
427
+ hubId: item.hubId,
428
+ hubName: item.hubName,
429
+ planName: item.planName,
430
+ planId: item.externalReference || void 0
431
+ }));
432
+ this.subscriptions = { value, fetchedAt: Date.now() };
433
+ return value;
434
+ }
435
+ // Report one billable use; Clearly.HUB bills the hub
436
+ async reportUsage(usage) {
437
+ const response = await this.config.fetch(this.config.urls.usage, {
438
+ method: "POST",
439
+ headers: {
440
+ "Content-Type": "application/json",
441
+ // This endpoint wants the token without "Bearer", for now
442
+ Authorization: await this.getAppToken()
443
+ },
444
+ body: JSON.stringify({
445
+ clientId: this.config.m2mClientId,
446
+ userId: usage.sub,
447
+ activeHubId: usage.hubId,
448
+ subscriptionId: usage.subscriptionId,
449
+ quantity: usage.quantity
450
+ })
451
+ });
452
+ if (!response.ok) {
453
+ const body = await response.json().catch(() => ({}));
454
+ throw new Error(`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`);
455
+ }
456
+ }
457
+ };
458
+ function pickSubscription(subscriptions, hubId) {
459
+ const ofHub = subscriptions.filter((subscription) => subscription.hubId === hubId);
460
+ return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
461
+ }
462
+
463
+ // src/oidc.ts
464
+ var oidc = __toESM(require("openid-client"), 1);
465
+ var SignIn = class {
466
+ constructor(config) {
467
+ this.config = config;
468
+ }
469
+ config;
470
+ configuration;
471
+ get callbackUrl() {
472
+ return `${this.config.appUrl}${this.config.basePath}/callback`;
473
+ }
474
+ // Discovery happens once, on the first sign-in
475
+ getConfiguration() {
476
+ this.configuration ??= oidc.discovery(new URL(this.config.urls.issuer), this.config.clientId, void 0, oidc.None()).catch((error) => {
477
+ this.configuration = void 0;
478
+ throw error;
479
+ });
480
+ return this.configuration;
481
+ }
482
+ async start(returnTo) {
483
+ const configuration = await this.getConfiguration();
484
+ const codeVerifier = oidc.randomPKCECodeVerifier();
485
+ const state = oidc.randomState();
486
+ const nonce = oidc.randomNonce();
487
+ const url = oidc.buildAuthorizationUrl(configuration, {
488
+ redirect_uri: this.callbackUrl,
489
+ scope: "openid email profile",
490
+ code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
491
+ code_challenge_method: "S256",
492
+ state,
493
+ nonce
494
+ });
495
+ return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
496
+ }
497
+ async finish(currentUrl, pending) {
498
+ const tokens = await oidc.authorizationCodeGrant(await this.getConfiguration(), currentUrl, {
499
+ pkceCodeVerifier: pending.codeVerifier,
500
+ expectedState: pending.state,
501
+ expectedNonce: pending.nonce
502
+ });
503
+ const claims = tokens.claims();
504
+ if (!claims) throw new Error("Clearly.HUB sent no ID token");
505
+ return {
506
+ person: {
507
+ sub: claims.sub,
508
+ email: String(claims.email ?? ""),
509
+ name: typeof claims.name === "string" ? claims.name : void 0
510
+ },
511
+ tokens: toSessionTokens(tokens)
512
+ };
513
+ }
514
+ async refresh(refreshToken) {
515
+ const tokens = await oidc.refreshTokenGrant(await this.getConfiguration(), refreshToken);
516
+ return toSessionTokens(tokens, refreshToken);
517
+ }
518
+ // Signing out of Clearly.HUB too, or the next sign-in goes straight through
519
+ signOutUrl() {
520
+ const url = new URL(`${this.config.urls.auth}/logout`);
521
+ url.searchParams.set("client_id", this.config.clientId);
522
+ url.searchParams.set("logout_uri", this.config.signedOutUrl);
523
+ return url.href;
524
+ }
525
+ };
526
+ function toSessionTokens(tokens, previousRefreshToken) {
527
+ return {
528
+ accessToken: tokens.access_token,
529
+ refreshToken: tokens.refresh_token ?? previousRefreshToken,
530
+ expiresAt: Math.floor(Date.now() / 1e3) + (tokens.expiresIn() ?? 3600)
531
+ };
532
+ }
533
+
534
+ // src/session.ts
535
+ var import_jose = require("jose");
536
+ var COOKIE_NAME = "clearly_hub";
537
+ var CHUNK_SIZE = 3800;
538
+ var MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
539
+ function readCookieHeader(source) {
540
+ if (source instanceof Request) return source.headers.get("cookie") ?? "";
541
+ const header = source.headers.cookie;
542
+ return Array.isArray(header) ? header.join("; ") : header ?? "";
543
+ }
544
+ function parseCookies(header) {
545
+ const cookies = /* @__PURE__ */ new Map();
546
+ for (const part of header.split(";")) {
547
+ const index = part.indexOf("=");
548
+ if (index > 0) cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
549
+ }
550
+ return cookies;
551
+ }
552
+ async function deriveKey(secret) {
553
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
554
+ return new Uint8Array(digest);
555
+ }
556
+ var SessionStore = class {
557
+ constructor(config) {
558
+ this.config = config;
559
+ this.keyPromise = deriveKey(config.secret);
560
+ }
561
+ config;
562
+ keyPromise;
563
+ async read(source) {
564
+ const cookies = parseCookies(readCookieHeader(source));
565
+ const chunks = [];
566
+ for (let i = 0; cookies.has(`${COOKIE_NAME}.${i}`); i++) {
567
+ chunks.push(cookies.get(`${COOKIE_NAME}.${i}`));
568
+ }
569
+ if (!chunks.length) return { data: {}, chunkCount: 0 };
570
+ try {
571
+ const { plaintext } = await (0, import_jose.compactDecrypt)(chunks.join(""), await this.keyPromise);
572
+ return { data: JSON.parse(new TextDecoder().decode(plaintext)), chunkCount: chunks.length };
573
+ } catch {
574
+ return { data: {}, chunkCount: chunks.length };
575
+ }
576
+ }
577
+ // Set-Cookie headers for the new session, clearing chunks it no longer needs
578
+ async write(data, previousChunkCount) {
579
+ const isEmpty = Object.keys(data).length === 0;
580
+ const chunks = isEmpty ? [] : (await this.encrypt(data)).match(new RegExp(`.{1,${CHUNK_SIZE}}`, "g"));
581
+ const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
582
+ const headers = chunks.map(
583
+ (chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
584
+ );
585
+ for (let i = chunks.length; i < previousChunkCount; i++) {
586
+ headers.push(`${COOKIE_NAME}.${i}=; Max-Age=0; ${attributes}`);
587
+ }
588
+ return headers;
589
+ }
590
+ async encrypt(data) {
591
+ return new import_jose.CompactEncrypt(new TextEncoder().encode(JSON.stringify(data))).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).encrypt(await this.keyPromise);
592
+ }
593
+ };
594
+
595
+ // src/index.ts
596
+ function clearlyHub(options) {
597
+ const config = resolveConfig(options);
598
+ const parts = {
599
+ config,
600
+ sessions: new SessionStore(config),
601
+ signIn: new SignIn(config),
602
+ screens: new Screens(config),
603
+ api: new HubApi(config)
604
+ };
605
+ const handler = createHandler(parts);
606
+ if (!parts.api.hasAppKey) {
607
+ console.warn(
608
+ 'Clearly.HUB: no m2mClientId/m2mClientSecret. Every hub shows "no subscription", and getSubscription() and reportUsage() fail.'
609
+ );
610
+ }
611
+ async function getUserToken(request) {
612
+ const { data } = await parts.sessions.read(request);
613
+ return getFreshAccessToken(data, parts);
614
+ }
615
+ return {
616
+ // Mount for any fetch-style server: Next.js route handlers, Hono, Bun, Deno
617
+ handler,
618
+ // Mount in Express: app.use('/clearly-hub', hub.express())
619
+ express: () => toNodeMiddleware(handler, config.appUrl),
620
+ // Who's signed in, for your own routes
621
+ async getUser(request) {
622
+ const { data } = await parts.sessions.read(request);
623
+ return data.user ?? null;
624
+ },
625
+ getUserToken,
626
+ // Fetch protected or paid data (for example from DATA_PICKER) with the
627
+ // person's key. Open data works without it.
628
+ async fetchAsUser(request, url, init = {}) {
629
+ const token = await getUserToken(request);
630
+ if (!token) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
631
+ const headers = new Headers(init.headers);
632
+ headers.set("Authorization", token);
633
+ return config.fetch(url, { ...init, headers });
634
+ },
635
+ // Your app's own key, for calls of your own to Clearly.HUB
636
+ getAppToken: () => parts.api.getAppToken(),
637
+ // Every hub that bought your app
638
+ getSubscriptions: () => parts.api.getSubscriptions(),
639
+ // A hub's subscription to your app: the active one, else one waiting for
640
+ // your approval, else null
641
+ async getSubscription(hubId) {
642
+ return pickSubscription(await parts.api.getSubscriptions(), hubId) ?? null;
643
+ },
644
+ // Report billable use by the signed-in person in a hub
645
+ async reportUsage({
646
+ request,
647
+ hubId,
648
+ quantity = 1
649
+ }) {
650
+ const { data } = await parts.sessions.read(request);
651
+ if (!data.user) throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
652
+ const subscription = pickSubscription(await parts.api.getSubscriptions(), hubId);
653
+ if (subscription?.status !== "ACTIVE") {
654
+ throw new Error(`Clearly.HUB: hub ${hubId} has no active subscription to your app`);
655
+ }
656
+ await parts.api.reportUsage({
657
+ sub: data.user.sub,
658
+ hubId,
659
+ subscriptionId: subscription.subscriptionId,
660
+ quantity
661
+ });
662
+ }
663
+ };
664
+ }
665
+ // Annotate the CommonJS export names for ESM import in node:
666
+ 0 && (module.exports = {
667
+ clearlyHub
668
+ });