@cosmoops/connect-sdk 0.34.0 → 0.35.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.
@@ -55,6 +55,99 @@ function sameState(a, b) {
55
55
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
56
56
  return diff === 0;
57
57
  }
58
+ /**
59
+ * How many sign-ins one browser may have in flight at once.
60
+ *
61
+ * The state cookie used to hold a *single* `state:next` pair, so a second
62
+ * `GET /signin` overwrote the first. Two tabs - or one abandoned attempt and
63
+ * one fresh one - and the older round trip came back to a cookie naming a
64
+ * state it had never heard of: `connect_error=state_mismatch`, which the host
65
+ * app's own session gate answers by silently firing another `/signin`, putting
66
+ * the visitor back on the sign-in screen with nothing on the page saying why.
67
+ *
68
+ * Five is far past what anyone opens by hand and still a cookie nobody
69
+ * notices - each entry is a 43-character state plus a short path. Entries are
70
+ * newest-first and the oldest fall off the end, so the tab a visitor is
71
+ * actually looking at is the last one to be forgotten.
72
+ *
73
+ * It does not weaken the CSRF check: a callback must still present one of
74
+ * these exact 256-bit values, and each still expires with the cookie.
75
+ */
76
+ const MAX_PENDING_STATES = 5;
77
+ /** `-` and `r` are the only flag fields this format writes - see `decodePendingState`. */
78
+ const RETRIED_FLAG = "r";
79
+ const NO_FLAGS = "-";
80
+ /**
81
+ * The state cookie's wire format: one entry per in-flight sign-in, newest
82
+ * first, joined by `|`.
83
+ *
84
+ * An entry is `state:flags:next` with the destination **percent-encoded**
85
+ * rather than written raw, and that is what makes splitting on a delimiter
86
+ * safe: `state` is base64url so it can hold neither, and an encoded path
87
+ * cannot be read as a field boundary however many colons or pipes it
88
+ * contains. The raw single-pair format this replaces is exactly why `retried`
89
+ * and the silent marker had to live outside the cookie - the split was too
90
+ * fragile to carry a third field.
91
+ */
92
+ function encodePendingStates(entries) {
93
+ return entries
94
+ .map((entry) => `${entry.state}:${entry.retried ? RETRIED_FLAG : NO_FLAGS}:${encodeURIComponent(entry.next)}`)
95
+ .join("|");
96
+ }
97
+ function decodePendingStates(raw) {
98
+ if (!raw)
99
+ return [];
100
+ const entries = [];
101
+ for (const part of raw.split("|")) {
102
+ const entry = decodePendingState(part);
103
+ if (entry)
104
+ entries.push(entry);
105
+ }
106
+ return entries;
107
+ }
108
+ /**
109
+ * One entry, tolerating the format this replaced.
110
+ *
111
+ * A cookie minted by the previous version is a single raw `state:next` pair,
112
+ * and sign-ins in flight across a deploy carry one. The flag field is a known
113
+ * one-character set, so anything else in that position means the remainder is
114
+ * a legacy destination - including one that itself contains a colon. Dropping
115
+ * those instead would answer every mid-deploy callback with
116
+ * `state_mismatch`.
117
+ */
118
+ function decodePendingState(part) {
119
+ const firstColon = part.indexOf(":");
120
+ if (firstColon <= 0)
121
+ return null;
122
+ const state = part.slice(0, firstColon);
123
+ const remainder = part.slice(firstColon + 1);
124
+ const secondColon = remainder.indexOf(":");
125
+ const flags = secondColon === -1 ? "" : remainder.slice(0, secondColon);
126
+ const hasFlags = flags === NO_FLAGS || flags === RETRIED_FLAG;
127
+ return {
128
+ state,
129
+ next: decodeDestination(hasFlags ? remainder.slice(secondColon + 1) : remainder),
130
+ retried: flags === RETRIED_FLAG,
131
+ };
132
+ }
133
+ /**
134
+ * `decodeURIComponent` throws on a lone `%`, which a legacy raw destination
135
+ * may well hold. The undecoded string then goes through `safeRedirect` like
136
+ * any other candidate, so a malformed one costs the default destination
137
+ * rather than a 500.
138
+ */
139
+ function decodeDestination(value) {
140
+ try {
141
+ return decodeURIComponent(value);
142
+ }
143
+ catch {
144
+ return value;
145
+ }
146
+ }
147
+ /** Appends `connect_error=<code>` to a destination that may already carry a query. */
148
+ function withError(next, code) {
149
+ return `${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(code)}`;
150
+ }
58
151
  function readCookie(request, name) {
59
152
  const header = request.headers.get("cookie");
60
153
  if (!header)
@@ -127,6 +220,10 @@ export function createConnectHandler(options) {
127
220
  * the cross-site redirect back from Connect, which is the one request that
128
221
  * has to read it. Scoped to `basePath` so it is never attached to ordinary
129
222
  * page loads.
223
+ *
224
+ * Holds every in-flight round trip rather than the latest one - see
225
+ * `MAX_PENDING_STATES` and `encodePendingStates`. An empty value clears it,
226
+ * which is what an exchange that consumed the last pending entry wants.
130
227
  */
131
228
  function stateCookie(value, secure) {
132
229
  const cleared = value === "";
@@ -956,6 +1053,17 @@ export function createConnectHandler(options) {
956
1053
  * alongside the post-login destination, and bounces the browser to
957
1054
  * Connect.
958
1055
  *
1056
+ * The new entry is **prepended to whatever is already pending** rather
1057
+ * than replacing it, so opening the app in a second tab no longer
1058
+ * invalidates the first - see `MAX_PENDING_STATES`.
1059
+ *
1060
+ * `?connect_retry=1` is stamped onto the entry here, which is the only
1061
+ * place that marker can be read: it is a query parameter on *this* URL,
1062
+ * and the `redirect_uri` handed to Connect is a registered, exact-match
1063
+ * value that cannot carry it. The callback used to look for it on its own
1064
+ * URL, where it could never arrive - so both retry bounds below were
1065
+ * unreachable and `login_required` could loop indefinitely.
1066
+ *
959
1067
  * Registered only when the session store can `write` - without that, the
960
1068
  * callback would have nowhere to put the session it just earned.
961
1069
  */
@@ -971,9 +1079,13 @@ export function createConnectHandler(options) {
971
1079
  state,
972
1080
  ...(prompt ? { prompt } : {}),
973
1081
  });
974
- // State and destination travel together in one cookie so the callback
1082
+ // State and destination travel together in one entry so the callback
975
1083
  // cannot end up with one and not the other.
976
- const cookies = [stateCookie(`${state}:${next}`, secure)];
1084
+ const pending = [
1085
+ { state, next, retried: url.searchParams.get("connect_retry") === "1" },
1086
+ ...decodePendingStates(readCookie(request, stateCookieName)),
1087
+ ].slice(0, MAX_PENDING_STATES);
1088
+ const cookies = [stateCookie(encodePendingStates(pending), secure)];
977
1089
  if (prompt === "none")
978
1090
  cookies.push(silentCookie("1", secure));
979
1091
  return redirect(authorizeUrl, cookies);
@@ -986,20 +1098,49 @@ export function createConnectHandler(options) {
986
1098
  "GET /callback": async (request) => {
987
1099
  const url = new URL(request.url);
988
1100
  const secure = url.protocol === "https:";
989
- const [cookieState, ...destinationParts] = (readCookie(request, stateCookieName) ?? "").split(":");
990
- const next = safeRedirect(destinationParts.join(":") || null, defaultRedirect);
991
- const clearState = stateCookie("", secure);
1101
+ const pending = decodePendingStates(readCookie(request, stateCookieName));
992
1102
  const returnedState = url.searchParams.get("state") ?? "";
993
- if (!cookieState || !sameState(cookieState, returnedState)) {
994
- // Either a forged callback or a genuinely stale tab. Neither should
995
- // mint a session, and neither is worth an error page.
996
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [
997
- clearState,
998
- silentCookie("", secure),
999
- ]);
1000
- }
1103
+ const matched = pending.find((entry) => sameState(entry.state, returnedState)) ?? null;
1104
+ // Every *other* in-flight sign-in survives this one. Clearing the whole
1105
+ // cookie is what made a second tab collateral damage: finishing in one
1106
+ // tab left the other holding a state the server had just forgotten.
1107
+ const settleState = stateCookie(encodePendingStates(pending.filter((entry) => entry !== matched)), secure);
1108
+ // Only ever this flow's own destination. With no match there is no
1109
+ // destination to trust - a `next` belonging to a different round trip is
1110
+ // somebody else's business, so that case takes the default.
1111
+ const next = safeRedirect(matched?.next ?? null, defaultRedirect);
1112
+ // Read before the state check, not after. A `prompt=none` probe must
1113
+ // never escalate into a visible sign-in - including the retry below -
1114
+ // and the mismatch branch is reachable for a silent probe whose cookie
1115
+ // has expired.
1001
1116
  const wasSilent = readCookie(request, silentCookieName) === "1";
1002
1117
  const clearSilent = silentCookie("", secure);
1118
+ /** One more interactive attempt, marked so the next pass can tell. */
1119
+ const retrySignIn = () => {
1120
+ const retry = new URL(`${url.origin}${basePath}/signin`);
1121
+ retry.searchParams.set("next", next);
1122
+ retry.searchParams.set("connect_retry", "1");
1123
+ return redirect(retry.toString(), [settleState, clearSilent]);
1124
+ };
1125
+ if (!matched) {
1126
+ // A stale tab whose cookie has expired, more entries in flight than
1127
+ // `MAX_PENDING_STATES` holds, or a forged callback. None of them mints
1128
+ // a session and none is worth an error page.
1129
+ //
1130
+ // One retry, because the overwhelmingly common cause is a round trip
1131
+ // whose own entry is simply gone and whose visitor Connect has already
1132
+ // signed in - that completes in a single hop. The bound reads *any*
1133
+ // pending entry's marker rather than the matched one, there being
1134
+ // none: a browser that has already been bounced once gets the error
1135
+ // instead of another lap. Without it this is the silent infinite loop,
1136
+ // since the host app's session gate answers `/dashboard` with no
1137
+ // session by firing `/signin` again.
1138
+ if (wasSilent)
1139
+ return redirect(next, [settleState, clearSilent]);
1140
+ if (!pending.some((entry) => entry.retried))
1141
+ return retrySignIn();
1142
+ return redirect(withError(next, "state_mismatch"), [settleState, clearSilent]);
1143
+ }
1003
1144
  const error = url.searchParams.get("error");
1004
1145
  if (error) {
1005
1146
  // A silent (`prompt=none`) probe that came back empty-handed. All
@@ -1014,27 +1155,20 @@ export function createConnectHandler(options) {
1014
1155
  // if they're already here" into "force everyone to log in", which is
1015
1156
  // the precise opposite of the feature.
1016
1157
  if (wasSilent) {
1017
- return redirect(next, [clearState, clearSilent]);
1158
+ return redirect(next, [settleState, clearSilent]);
1018
1159
  }
1019
1160
  // `login_required` is normal traffic, not a fault: a new account gets
1020
1161
  // pulled out to their inbox to verify, and the ~15 minute request
1021
1162
  // expires before they return. Connect has already signed them in, so
1022
- // one silent retry completes in a single hop. `connect_retry` bounds
1023
- // it - a genuine loop must surface, not spin.
1024
- if (error === "login_required" && url.searchParams.get("connect_retry") !== "1") {
1025
- const retry = new URL(`${url.origin}${basePath}/signin`);
1026
- retry.searchParams.set("next", next);
1027
- retry.searchParams.set("connect_retry", "1");
1028
- return redirect(retry.toString(), [clearState, clearSilent]);
1029
- }
1030
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
1031
- clearState,
1032
- clearSilent,
1033
- ]);
1163
+ // one silent retry completes in a single hop. The marker on this
1164
+ // flow's own entry bounds it - a genuine loop must surface, not spin.
1165
+ if (error === "login_required" && !matched.retried)
1166
+ return retrySignIn();
1167
+ return redirect(withError(next, error), [settleState, clearSilent]);
1034
1168
  }
1035
1169
  const code = url.searchParams.get("code");
1036
1170
  if (!code) {
1037
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState, clearSilent]);
1171
+ return redirect(withError(next, "missing_code"), [settleState, clearSilent]);
1038
1172
  }
1039
1173
  const result = await client.identity.exchangeCode({
1040
1174
  code,
@@ -1043,7 +1177,7 @@ export function createConnectHandler(options) {
1043
1177
  // Verified before the session is stored, not after - an unverifiable
1044
1178
  // token must never become a signed-in user.
1045
1179
  await client.identity.verifyIdToken(result.idToken);
1046
- const response = redirect(next, [clearState, clearSilent]);
1180
+ const response = redirect(next, [settleState, clearSilent]);
1047
1181
  await session.write(request, response, result.sessionToken);
1048
1182
  return response;
1049
1183
  },
@@ -25,22 +25,51 @@ export declare const RAIL_GUTTER = 12;
25
25
  * The row shape every sidebar control shares - nav items, the workspace tile,
26
26
  * the launcher, the user button.
27
27
  *
28
+ * **The height is not here.** It rides on `SIDEBAR_ROW_ICON` /
29
+ * `SIDEBAR_ROW_TILE` instead, because the two glyph sizes want different
30
+ * heights and only one of them is in the region that overflows - see those
31
+ * two constants.
32
+ *
28
33
  * `overflow-hidden` + `whitespace-nowrap` is what makes the collapse smooth:
29
34
  * the label is *clipped* by the shrinking rail rather than re-wrapped or
30
35
  * re-measured, so a width animation costs no reflow. Fading the label with
31
36
  * `SidebarLabel` on top of that hides the clip itself.
32
37
  */
33
- export declare const SIDEBAR_ROW = "group/row relative flex h-10 w-full shrink-0 items-center justify-start gap-3 overflow-hidden whitespace-nowrap rounded-lg pe-2 text-sm font-normal transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus";
38
+ export declare const SIDEBAR_ROW = "group/row relative flex w-full shrink-0 items-center justify-start gap-3 overflow-hidden whitespace-nowrap rounded-lg pe-2 text-sm font-normal transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus";
34
39
  /**
35
- * Leading inset for a row whose glyph is 20px wide (a nav icon, the launcher).
40
+ * Leading inset *and height* for a row whose glyph is 20px wide (a nav icon,
41
+ * the launcher, a secondary-nav row).
42
+ *
36
43
  * `10 + 10 = 20`, and `RAIL_GUTTER + 20 = 32`. See `RAIL_COLLAPSED`.
44
+ *
45
+ * **36px, not 40px, and the height belongs on this axis rather than on
46
+ * `SIDEBAR_ROW`.** The nav is the only region of the rail that grows with the
47
+ * host's route count, and `ConnectSidebarBody` is the only region that
48
+ * scrolls - so a rail that overflows overflows here. Ten destinations at 40px
49
+ * came to 436px of nav against 273px of fixed chrome (header, switcher, two
50
+ * footers), which needs a 709px viewport to sit still; a 1366x768 screen has
51
+ * about 678px of it. Four pixels a row is 48px back across the twelve rows a
52
+ * real integration draws, and 36px is still comfortably past the 24px
53
+ * WCAG 2.5.8 target minimum with the row's full width as the hit area.
54
+ *
55
+ * It is deliberately *not* a smaller glyph. The 20px box is a flex centring
56
+ * container for an icon the **host** supplies at its own size, so shrinking
57
+ * the box buys no vertical space and only pushes the icon off the 32px centre
58
+ * the collapsed rail is built on.
37
59
  */
38
- export declare const SIDEBAR_ROW_ICON = "ps-[10px]";
60
+ export declare const SIDEBAR_ROW_ICON = "h-9 ps-[10px]";
39
61
  /**
40
- * Leading inset for a row whose glyph is 32px wide (the avatar, the workspace
41
- * tile). `4 + 16 = 20`, landing on the same 32px centre as `SIDEBAR_ROW_ICON`.
62
+ * Leading inset and height for a row whose glyph is 32px wide (the avatar, the
63
+ * workspace tile). `4 + 16 = 20`, landing on the same 32px centre as
64
+ * `SIDEBAR_ROW_ICON`.
65
+ *
66
+ * **These stay at 40px.** There are exactly two of them and neither scales
67
+ * with the host's nav, so they are not what overflows - and both need the
68
+ * height: a 32px tile in a 36px row has 2px of air, and the account row's
69
+ * label is two lines (name over email, `leading-tight`) which measures ~32px
70
+ * on its own.
42
71
  */
43
- export declare const SIDEBAR_ROW_TILE = "ps-[4px]";
72
+ export declare const SIDEBAR_ROW_TILE = "h-10 ps-[4px]";
44
73
  /**
45
74
  * The class list for one sidebar row, in one place.
46
75
  *
@@ -57,7 +86,10 @@ export declare const SIDEBAR_ROW_TILE = "ps-[4px]";
57
86
  * lets a `w-fit`, centred, `px-`-padded button take this shape at all.
58
87
  */
59
88
  export declare function sidebarRowClass(options?: Readonly<{
60
- /** The glyph is 32px (an avatar, a workspace tile) rather than 20px. */
89
+ /**
90
+ * The glyph is 32px (an avatar, a workspace tile) rather than 20px - which
91
+ * also makes the row 40px rather than 36px. See `SIDEBAR_ROW_TILE`.
92
+ */
61
93
  tile?: boolean;
62
94
  /** Paint the active treatment. */
63
95
  active?: boolean;
@@ -73,13 +73,13 @@ export declare function ConnectSidebarFooter({ children, separated, }: Readonly<
73
73
  separated?: boolean;
74
74
  }>): import("react").JSX.Element;
75
75
  export type ConnectSidebarRowProps = Readonly<{
76
- /** The glyph. 20px by default; 32px when `tile`. */
76
+ /** The glyph. 20px in a 36px row by default; 32px in a 40px row when `tile`. */
77
77
  icon: ReactNode;
78
78
  /** The row's text, and - when it is a string - its accessible name. */
79
79
  label: ReactNode;
80
80
  /** Overrides the accessible name. Required when `label` is not a string. */
81
81
  "aria-label"?: string;
82
- /** The glyph is 32px (an avatar, a workspace tile) rather than 20px. */
82
+ /** The glyph is 32px (an avatar, a workspace tile) rather than 20px, in a 40px row. */
83
83
  tile?: boolean;
84
84
  /** Trailing content, e.g. a chevron. Clipped with the label while collapsed. */
85
85
  trailing?: ReactNode;
@@ -61,6 +61,12 @@ export declare function ConnectSubscriptionCard({ plansHref, className }: Connec
61
61
  * count against, so it renders nothing rather than a meter that is always
62
62
  * empty. Under `"per-seat"` going over is billable rather than blocked, so it
63
63
  * says what the extra costs instead of warning.
64
+ *
65
+ * `detail.seatCount` is a live count of the members holding a role for this
66
+ * application. It used to be the subscription's last-billed snapshot, which
67
+ * only ever moved on a `"per-seat"` plan - so a `"fixed"` single-seat plan
68
+ * read "0 of 1 seat used" permanently, including when its one seat was taken
69
+ * and Connect was already refusing the next assignment.
64
70
  */
65
71
  export declare function ConnectSeatUsage({ plan, detail, className, }: Readonly<{
66
72
  plan: SubscribedPlan;
@@ -9,6 +9,55 @@ import type { UserResponse } from "../types.js";
9
9
  * are frequently absent.
10
10
  */
11
11
  export declare function connectClient(overrides?: Partial<ConnectClientOptions>): ConnectClient;
12
+ /**
13
+ * The `ConnectProvider` props that come from the environment.
14
+ *
15
+ * ```tsx
16
+ * // app/layout.tsx - a Server Component
17
+ * import { connectProviderProps } from "@cosmoops/connect-sdk/next";
18
+ * import { ConnectProvider } from "@cosmoops/connect-sdk/client";
19
+ *
20
+ * <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
21
+ * ```
22
+ *
23
+ * ## What it is for
24
+ *
25
+ * `CONNECT_APP_URL` points the *server* at a non-production Connect. It is not
26
+ * `NEXT_PUBLIC_`, so it never reaches the browser, and `ConnectProvider`
27
+ * defaults `appUrl` to the production origin independently - so pointing the
28
+ * server somewhere else and leaving the prop off splits the integration in
29
+ * half. The server talks to dev and every destination the browser builds opens
30
+ * production: the user menu's Profile and Billing rows, the app launcher's link
31
+ * for an unsubscribed application, `ConnectAccessGate`'s subscribe target, and
32
+ * checkout. Nothing errors. The customer is simply asked to buy a second
33
+ * subscription in an instance where they have none.
34
+ *
35
+ * Both apps built on this SDK shipped that bug before anyone noticed, from
36
+ * reading "the provider takes no props" as a rule rather than as a remark about
37
+ * `clientId`. A root layout is a Server Component, so one spread fixes it with
38
+ * no browser-visible copy of the variable.
39
+ *
40
+ * ## What it deliberately leaves out
41
+ *
42
+ * `clientId` and `basePath`. The first is only needed alongside an explicit
43
+ * `redirectUri`, when you build the authorize URL in the browser rather than
44
+ * letting `signIn()` bounce through your own `/signin` route - and handing the
45
+ * browser a value it does not need is surface for nothing. The second is where
46
+ * *you* mounted the handler, which the environment does not know. Pass either
47
+ * yourself; a prop you pass always wins over this spread, whichever order you
48
+ * write them in, as long as the spread comes first.
49
+ *
50
+ * ## If you already have an origin helper
51
+ *
52
+ * Use it instead. An app that builds its own links into Connect (a "Manage
53
+ * team" button, a pricing page's subscribe target) needs that origin on the
54
+ * server too, and one function owning the read is better than this helper
55
+ * beside it - two readers of one variable is the shape that caused this. This
56
+ * exists for the integration that has no such helper, which is most of them.
57
+ */
58
+ export declare function connectProviderProps(): {
59
+ appUrl: string;
60
+ };
12
61
  /**
13
62
  * An httpOnly cookie holding the opaque `sessionToken`.
14
63
  *
@@ -138,4 +138,4 @@ export declare class ConnectClient {
138
138
  constructor(options: ConnectClientOptions);
139
139
  }
140
140
  /** Kept in step with `package.json` by `sdk.test.ts`. */
141
- export declare const SDK_VERSION = "0.34.0";
141
+ export declare const SDK_VERSION = "0.35.0";
@@ -442,6 +442,14 @@ export interface SubscriptionDetail {
442
442
  id: string;
443
443
  status: SubscriptionStatus;
444
444
  cancelAtPeriodEnd: boolean;
445
+ /**
446
+ * How many workspace members currently hold a role for your application,
447
+ * counted when you read it. Compare it against `plan.seats.included`.
448
+ *
449
+ * `0` on an `"unlimited"` plan - there is no ceiling to count against, so
450
+ * Connect does not pay for the count. `ConnectSeatUsage` renders nothing in
451
+ * that case for the same reason.
452
+ */
445
453
  seatCount: number;
446
454
  addons: string[];
447
455
  currentBillingCycle: {
@@ -474,6 +482,13 @@ export interface ApplicationSubscriber {
474
482
  /** `null` if the plan was hard-deleted rather than archived. */
475
483
  planName: string | null;
476
484
  status: "created" | "authenticated" | "active" | "paused" | "cancelled";
485
+ /**
486
+ * The last-billed seat snapshot, not the live count `SubscriptionDetail`
487
+ * carries: a page of subscribers cannot afford one member scan per row. It
488
+ * is `0` unless the plan is `"per-seat"`, which is the only model that
489
+ * bills by seat. Read the single workspace's subscription for "who is
490
+ * assigned right now".
491
+ */
477
492
  seatCount: number;
478
493
  /** ISO 8601. */
479
494
  subscribedAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",