@canopy-io/node 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @canopy-io/node
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6cbddf8: `LocalAuthorizer` answers the list question, says why a check was denied, and hears every verdict.
8
+
9
+ - `grantRoots(identityId, permission)`: the nodes the permission is granted at, in effect now, minus the ones another root already covers, with `envWide` when one is the Environment's root.
10
+ - `accessibleNodes(identityId, permission, { maxNodes })`: those roots expanded through their descendants into a `Set` for an `IN (...)` filter, from the same cached tree a check walks. Capped at `maxNodes` (10,000 by default, settable per authorizer or per call); past the cap `nodes` is null and `complete` false, and the roots filter a closure table or materialised path instead.
11
+ - `permissionsAt(identityId, nodeId)`: every permission the identity holds at the node, by grant or inheritance; `permissionsAtMany(identityId, nodeIds)` answers several nodes from one read.
12
+ - `evaluate` returns `{ allowed, reason }`. On a denial `reason` is `permission_not_held`, `outside_grants`, `expired`, `not_yet_effective` or `node_missing`. The grant roots are read with `include=windows`, so a grant that ends before the TTL is re-read at its boundary.
13
+ - `onDecision` and `onDenied` options, fired after every verdict and never awaited; a hook that throws is ignored.
14
+ - `canopy.identities.accessibleNodes(id, { permission, max_nodes })` wraps `GET /api/v1/identities/:id/accessible-nodes`, and `canopy.identities.permissions(id, { node_id })` takes the node.
15
+
16
+ This version reads `GET /api/v1/identities/:id/grants?include=windows`, which Canopy serves from the same release that adds the accessible-nodes endpoint; against an older API the read is refused and every check fails, so upgrade after the API has it.
17
+
18
+ `@canopy-io/nestjs` accepts `onDecision` and `onDenied` in `CanopyModuleOptions` and passes them to the authorizer; the guard's own behaviour is unchanged.
19
+
3
20
  ## 0.10.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -270,6 +270,53 @@ up to five minutes (`300_000`); a longer value throws when the authorizer is
270
270
  created. Shortening it below the gap between a user's requests saves nothing:
271
271
  every request then finds the cache expired and refetches.
272
272
 
273
+ Each answer carries a reason when it is no, from what the grant roots and the
274
+ tree can say: `permission_not_held`, `outside_grants`, `expired`,
275
+ `not_yet_effective`, or `node_missing` for a `node` check with no node. The
276
+ grant roots are read with their windows, so a grant that ends sooner than the
277
+ TTL is dropped at its boundary, not at the next expiry. `onDecision` and
278
+ `onDenied` hooks hear every verdict after it is reached, never awaited and
279
+ never able to change it, for audit trails and alerting without a fork of the
280
+ guard.
281
+
282
+ ```ts
283
+ const authorizer = new LocalAuthorizer(canopy.client, {
284
+ onDenied: ({ query, decision }) =>
285
+ audit.record("denied", { ...query, reason: decision.reason }),
286
+ });
287
+ ```
288
+
289
+ A list needs the other direction: which nodes may this identity see. The same
290
+ cache answers it, in the shape your query wants. `grantRoots` names the nodes
291
+ the permission is granted at, in effect now, minus the ones another root
292
+ already covers, and says whether one is the Environment's root (`envWide`:
293
+ every node qualifies, so no filter). `accessibleNodes` expands those roots
294
+ through their descendants into a `Set` for an `IN (...)` filter, capped at
295
+ `maxNodes` (10,000 by default, per authorizer or per call); past the cap it
296
+ hands back the roots with `nodes: null` and `complete: false`, so an
297
+ application with a closure table or a materialised path filters by "beneath a
298
+ root" instead. `permissionsAt` lists what the identity holds at one node, for
299
+ rendering a screen's controls, and `permissionsAtMany` answers several nodes
300
+ from one read, for the rows of a list.
301
+
302
+ ```ts
303
+ const access = await authorizer.accessibleNodes(identityId, "reports.view");
304
+
305
+ if (access.envWide) {
306
+ return db.reports.findMany();
307
+ }
308
+
309
+ if (!access.complete) {
310
+ // Too large for an IN (...) list: filter beneath access.roots instead.
311
+ }
312
+
313
+ return db.reports.findMany({ where: { nodeId: { in: [...access.nodes] } } });
314
+ ```
315
+
316
+ A list filtered this way is what to show; the write that follows still checks
317
+ its node. Canopy answers the same question fresh, for a process without the
318
+ authorizer, as `canopy.identities.accessibleNodes(id, { permission })`.
319
+
273
320
  `canopy.permissions.evaluate` remains for a decision with no staleness at all.
274
321
 
275
322
  `authorizer.invalidate(identityId)` drops one identity's cached grants, so
package/dist/index.cjs CHANGED
@@ -604,10 +604,28 @@ var Identities = class {
604
604
  * question at the node that matters. This is for showing a person what
605
605
  * someone can do.
606
606
  */
607
- permissions(id) {
607
+ permissions(id, query = {}) {
608
608
  return this.client.request(
609
609
  "GET",
610
- `/api/v1/identities/${encodeURIComponent(id)}/permissions`
610
+ `/api/v1/identities/${encodeURIComponent(id)}/permissions`,
611
+ { query: { ...query } }
612
+ );
613
+ }
614
+ /**
615
+ * Where the identity may act with one permission, answered by Canopy for a
616
+ * list query: the minimal grant roots, and every node beneath them up to
617
+ * `max_nodes` (null past it, with `complete: false`), or `env_wide` when a
618
+ * root is the Environment's own root and no filter is needed.
619
+ *
620
+ * `LocalAuthorizer.accessibleNodes` answers the same question from its
621
+ * cache without a call; this is the fresh answer, and the one for a process
622
+ * that does not run the authorizer.
623
+ */
624
+ accessibleNodes(id, query) {
625
+ return this.client.request(
626
+ "GET",
627
+ `/api/v1/identities/${encodeURIComponent(id)}/accessible-nodes`,
628
+ { query: { ...query } }
611
629
  );
612
630
  }
613
631
  /**
@@ -1151,6 +1169,7 @@ var Canopy = class {
1151
1169
  // src/authorizer.ts
1152
1170
  var DEFAULT_TTL_MS = 6e4;
1153
1171
  var MAX_TTL_MS = 3e5;
1172
+ var DEFAULT_MAX_NODES = 1e4;
1154
1173
  var MAX_WALK_DEPTH = 256;
1155
1174
  var PAGE_SIZE = 100;
1156
1175
  var MAX_PAGES = 1e4;
@@ -1167,11 +1186,20 @@ function understood(page, path) {
1167
1186
  }
1168
1187
  var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
1169
1188
  var PARENTS_PATH = "/api/v1/nodes/parents";
1189
+ function inEffect(window, now) {
1190
+ return (window.from === null || window.from <= now) && (window.to === null || window.to > now);
1191
+ }
1192
+ function reasonAmong(windows, now) {
1193
+ return windows.some((window) => window.to !== null && window.to <= now) ? "expired" : "not_yet_effective";
1194
+ }
1170
1195
  var LocalAuthorizer = class {
1171
1196
  client;
1172
1197
  ttlMs;
1198
+ maxNodes;
1173
1199
  now;
1174
1200
  readOptions;
1201
+ onDecision;
1202
+ onDenied;
1175
1203
  grants = /* @__PURE__ */ new Map();
1176
1204
  tree;
1177
1205
  /**
@@ -1199,22 +1227,39 @@ var LocalAuthorizer = class {
1199
1227
  `ttlMs must be between 0 and ${MAX_TTL_MS} (five minutes); got ${String(this.ttlMs)}. It is how long a revoked user keeps access, so it is capped.`
1200
1228
  );
1201
1229
  }
1230
+ this.maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
1231
+ if (!Number.isInteger(this.maxNodes) || this.maxNodes < 1) {
1232
+ throw new RangeError(
1233
+ `maxNodes must be a positive integer; got ${String(this.maxNodes)}.`
1234
+ );
1235
+ }
1202
1236
  this.now = options.now ?? (() => Date.now());
1237
+ this.onDecision = options.onDecision;
1238
+ this.onDenied = options.onDenied;
1203
1239
  this.readOptions = {
1204
1240
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
1205
1241
  ...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
1206
1242
  };
1207
1243
  }
1208
1244
  /**
1209
- * Whether the identity holds the permission.
1245
+ * Whether the identity holds the permission, and why not when it does not.
1210
1246
  *
1211
1247
  * Shaped like the API's own evaluate so a caller can swap one for the other.
1212
1248
  * A `node` check with no node is a denial rather than an error: a request
1213
1249
  * whose subject cannot be established is exactly the one that must not pass.
1214
1250
  */
1215
1251
  async evaluate(query, options = {}) {
1252
+ const started = this.now();
1253
+ const decided = (decision) => {
1254
+ this.announce({
1255
+ query,
1256
+ decision,
1257
+ durationMs: Math.max(0, this.now() - started)
1258
+ });
1259
+ return decision;
1260
+ };
1216
1261
  if (!options.signal) {
1217
- return this.decide(query);
1262
+ return decided(await this.decide(query));
1218
1263
  }
1219
1264
  const signal = options.signal;
1220
1265
  let onAbort;
@@ -1231,25 +1276,157 @@ var LocalAuthorizer = class {
1231
1276
  onAbort = fail;
1232
1277
  signal.addEventListener("abort", fail, { once: true });
1233
1278
  });
1234
- return Promise.race([this.decide(query), aborted]).finally(() => {
1279
+ return Promise.race([this.decide(query), aborted]).then(decided).finally(() => {
1235
1280
  if (onAbort) {
1236
1281
  signal.removeEventListener("abort", onAbort);
1237
1282
  }
1238
1283
  });
1239
1284
  }
1285
+ /**
1286
+ * The nodes the identity holds the permission at, in effect now, reduced to
1287
+ * the ones no other such node already covers, and whether one of them is
1288
+ * the Environment's root. The roots alone are enough for an application
1289
+ * that keeps a closure table or a materialised path: it filters by "beneath
1290
+ * one of these" without an expanded set.
1291
+ *
1292
+ * Reads the tree, since minimality and `envWide` are questions about it; a
1293
+ * root the readable hierarchy does not contain raises
1294
+ * `authorizer.hierarchy_incomplete`, as a check at such a node does.
1295
+ */
1296
+ async grantRoots(identityId, permission) {
1297
+ const now = this.now();
1298
+ const held = (await this.grantRootsFor(identityId)).get(permission) ?? [];
1299
+ const granted = new Set(
1300
+ held.filter((window) => inEffect(window, now)).map((w) => w.node)
1301
+ );
1302
+ if (granted.size === 0) {
1303
+ return { roots: [], envWide: false };
1304
+ }
1305
+ const { parents } = await this.treeEntry();
1306
+ const roots = [];
1307
+ let envWide = false;
1308
+ for (const node of granted) {
1309
+ if (!parents.has(node)) {
1310
+ throw this.hierarchyIncomplete(node);
1311
+ }
1312
+ if (parents.get(node) === null) {
1313
+ envWide = true;
1314
+ }
1315
+ let covered = false;
1316
+ let depth = 0;
1317
+ for (let current = parents.get(node); current && depth < MAX_WALK_DEPTH; current = parents.get(current), depth += 1) {
1318
+ if (granted.has(current)) {
1319
+ covered = true;
1320
+ break;
1321
+ }
1322
+ }
1323
+ if (!covered) {
1324
+ roots.push(node);
1325
+ }
1326
+ }
1327
+ return { roots, envWide };
1328
+ }
1329
+ /**
1330
+ * Where the identity may act with the permission, as the set a list query
1331
+ * filters by: {@link grantRoots} expanded through their descendants, up to
1332
+ * `maxNodes` (the authorizer's default unless given here).
1333
+ *
1334
+ * Past the cap `nodes` is null and `complete` false: the set is too large
1335
+ * to be worth an `IN (...)` list, and the caller filters by `roots`
1336
+ * instead. An `envWide` answer carries no set either, since every node
1337
+ * qualifies and a filter would only slow the query down.
1338
+ *
1339
+ * Computed from the same cached grant roots and tree a check uses, so a
1340
+ * list filtered by it and the check on a write cannot disagree, and it goes
1341
+ * stale on the same `ttlMs`.
1342
+ */
1343
+ async accessibleNodes(identityId, permission, options = {}) {
1344
+ const cap = options.maxNodes ?? this.maxNodes;
1345
+ const { roots, envWide } = await this.grantRoots(identityId, permission);
1346
+ if (envWide) {
1347
+ return { roots, envWide: true, nodes: null, complete: true };
1348
+ }
1349
+ if (roots.length === 0) {
1350
+ return { roots, envWide: false, nodes: /* @__PURE__ */ new Set(), complete: true };
1351
+ }
1352
+ const { children } = await this.treeEntry();
1353
+ const nodes = /* @__PURE__ */ new Set();
1354
+ const pending = [...roots];
1355
+ while (pending.length > 0) {
1356
+ const node = pending.pop();
1357
+ if (nodes.has(node)) {
1358
+ continue;
1359
+ }
1360
+ nodes.add(node);
1361
+ if (nodes.size > cap) {
1362
+ return { roots, envWide: false, nodes: null, complete: false };
1363
+ }
1364
+ for (const child of children.get(node) ?? []) {
1365
+ pending.push(child);
1366
+ }
1367
+ }
1368
+ return { roots, envWide: false, nodes, complete: true };
1369
+ }
1370
+ /**
1371
+ * Every permission the identity holds at the node, by grant or by
1372
+ * inheritance: the list a screen renders its controls from, answered from
1373
+ * the same cached grant roots and tree a check uses.
1374
+ */
1375
+ async permissionsAt(identityId, nodeId) {
1376
+ const answers = await this.permissionsAtMany(identityId, [nodeId]);
1377
+ return answers.get(nodeId) ?? [];
1378
+ }
1379
+ /**
1380
+ * {@link permissionsAt} for several nodes at once, as node → permissions:
1381
+ * the rows of a list each with their own controls, answered from one read
1382
+ * of the grant roots and one walk per node.
1383
+ */
1384
+ async permissionsAtMany(identityId, nodeIds) {
1385
+ const now = this.now();
1386
+ const roots = await this.grantRootsFor(identityId);
1387
+ const answers = /* @__PURE__ */ new Map();
1388
+ for (const nodeId of new Set(nodeIds)) {
1389
+ const path = await this.lineageOf(nodeId);
1390
+ const held = [];
1391
+ for (const [permission, windows] of roots) {
1392
+ if (windows.some(
1393
+ (window) => path.has(window.node) && inEffect(window, now)
1394
+ )) {
1395
+ held.push(permission);
1396
+ }
1397
+ }
1398
+ answers.set(nodeId, held.sort());
1399
+ }
1400
+ return answers;
1401
+ }
1240
1402
  async decide(query) {
1241
- const roots = await this.grantRootsFor(query.identity_id);
1242
- const granted = roots.get(query.permission);
1243
- if (!granted || granted.size === 0) {
1244
- return { allowed: false };
1403
+ const now = this.now();
1404
+ const held = (await this.grantRootsFor(query.identity_id)).get(
1405
+ query.permission
1406
+ );
1407
+ if (!held || held.length === 0) {
1408
+ return { allowed: false, reason: "permission_not_held" };
1245
1409
  }
1246
1410
  if ((query.scope ?? "node") === "app_wide") {
1247
- return { allowed: true };
1411
+ return held.some((window) => inEffect(window, now)) ? { allowed: true, reason: null } : { allowed: false, reason: reasonAmong(held, now) };
1248
1412
  }
1249
1413
  if (!query.node_id) {
1250
- return { allowed: false };
1414
+ return { allowed: false, reason: "node_missing" };
1415
+ }
1416
+ if (held.some(
1417
+ (window) => window.node === query.node_id && inEffect(window, now)
1418
+ )) {
1419
+ return { allowed: true, reason: null };
1420
+ }
1421
+ const path = await this.lineageOf(query.node_id);
1422
+ const reachable = held.filter((window) => path.has(window.node));
1423
+ if (reachable.some((window) => inEffect(window, now))) {
1424
+ return { allowed: true, reason: null };
1251
1425
  }
1252
- return { allowed: await this.holdsAtNode(granted, query.node_id) };
1426
+ if (reachable.length === 0) {
1427
+ return { allowed: false, reason: "outside_grants" };
1428
+ }
1429
+ return { allowed: false, reason: reasonAmong(reachable, now) };
1253
1430
  }
1254
1431
  /** Counters for observability — how much traffic the cache is actually saving. */
1255
1432
  snapshot() {
@@ -1285,28 +1462,42 @@ var LocalAuthorizer = class {
1285
1462
  this.grants.clear();
1286
1463
  this.tree = void 0;
1287
1464
  }
1288
- /** Climb from `nodeId` and look for a grant root among its ancestors. */
1289
- async holdsAtNode(granted, nodeId) {
1290
- if (granted.has(nodeId)) {
1291
- return true;
1465
+ /** Fire the decision hooks, after the verdict and without waiting on them. */
1466
+ announce(event) {
1467
+ for (const hook of [
1468
+ this.onDecision,
1469
+ event.decision.allowed ? void 0 : this.onDenied
1470
+ ]) {
1471
+ if (!hook) {
1472
+ continue;
1473
+ }
1474
+ try {
1475
+ hook(event);
1476
+ } catch {
1477
+ }
1292
1478
  }
1293
- const parents = await this.parents();
1479
+ }
1480
+ /** The node and its ancestors, from the tree this client can read. */
1481
+ async lineageOf(nodeId) {
1482
+ const { parents } = await this.treeEntry();
1294
1483
  if (!parents.has(nodeId)) {
1295
- throw new CanopyAuthorizerError(
1296
- "authorizer.hierarchy_incomplete",
1297
- `The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
1298
- );
1484
+ throw this.hierarchyIncomplete(nodeId);
1299
1485
  }
1486
+ const path = /* @__PURE__ */ new Set([nodeId]);
1300
1487
  let current = parents.get(nodeId);
1301
1488
  let depth = 0;
1302
1489
  while (current && depth < MAX_WALK_DEPTH) {
1303
- if (granted.has(current)) {
1304
- return true;
1305
- }
1490
+ path.add(current);
1306
1491
  current = parents.get(current);
1307
1492
  depth += 1;
1308
1493
  }
1309
- return false;
1494
+ return path;
1495
+ }
1496
+ hierarchyIncomplete(nodeId) {
1497
+ return new CanopyAuthorizerError(
1498
+ "authorizer.hierarchy_incomplete",
1499
+ `The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
1500
+ );
1310
1501
  }
1311
1502
  async grantRootsFor(identityId) {
1312
1503
  const cached = this.grants.get(identityId);
@@ -1324,12 +1515,19 @@ var LocalAuthorizer = class {
1324
1515
  return read;
1325
1516
  }
1326
1517
  /**
1327
- * Every page of the identity's grant roots, folded into permission → nodes.
1518
+ * Every page of the identity's grant roots, with their windows, folded
1519
+ * into permission → roots.
1520
+ *
1521
+ * Each row is one permission at one node for one assignment, so a
1522
+ * permission granted at three nodes arrives as three rows, possibly on
1523
+ * different pages. Stopping after the first page would under-report what
1524
+ * the identity holds: a false deny, the safe direction, but a wrong answer
1525
+ * all the same.
1328
1526
  *
1329
- * Each row is one permission at one node, so a permission granted at three
1330
- * nodes arrives as three rows, possibly on different pages. Stopping after
1331
- * the first page would under-report what the identity holds: a false deny,
1332
- * the safe direction, but a wrong answer all the same.
1527
+ * Rows outside their window are kept: they are what lets a denial say
1528
+ * `expired` or `not_yet_effective`, and the entry expires at the nearest
1529
+ * window boundary when that comes before the TTL, so a grant takes effect
1530
+ * or ends on time rather than up to `ttlMs` late.
1333
1531
  */
1334
1532
  async fetchGrantRoots(identityId) {
1335
1533
  this.stats.grantFetches += 1;
@@ -1339,30 +1537,42 @@ var LocalAuthorizer = class {
1339
1537
  this.stats.grantPages += 1;
1340
1538
  return this.client.request("GET", path, {
1341
1539
  ...this.readOptions,
1342
- query
1540
+ query: { ...query, include: "windows" }
1343
1541
  }).then((page) => understood(page, path));
1344
1542
  },
1345
1543
  { page: 1, take: PAGE_SIZE },
1346
1544
  { maxPages: MAX_PAGES }
1347
1545
  );
1348
1546
  const roots = /* @__PURE__ */ new Map();
1547
+ const now = this.now();
1548
+ let boundary = Number.POSITIVE_INFINITY;
1349
1549
  for await (const row of rows) {
1350
- const nodes = roots.get(row.permission);
1351
- if (nodes) {
1352
- nodes.add(row.node_id);
1550
+ const window = {
1551
+ node: row.node_id,
1552
+ from: row.effective_from ? Date.parse(row.effective_from) : null,
1553
+ to: row.effective_to ? Date.parse(row.effective_to) : null
1554
+ };
1555
+ for (const edge of [window.from, window.to]) {
1556
+ if (edge !== null && edge > now && edge < boundary) {
1557
+ boundary = edge;
1558
+ }
1559
+ }
1560
+ const windows = roots.get(row.permission);
1561
+ if (windows) {
1562
+ windows.push(window);
1353
1563
  } else {
1354
- roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
1564
+ roots.set(row.permission, [window]);
1355
1565
  }
1356
1566
  }
1357
1567
  this.grants.set(identityId, {
1358
1568
  roots,
1359
- expiresAt: this.now() + this.ttlMs
1569
+ expiresAt: Math.min(now + this.ttlMs, boundary)
1360
1570
  });
1361
1571
  return roots;
1362
1572
  }
1363
- async parents() {
1573
+ async treeEntry() {
1364
1574
  if (this.tree && this.tree.expiresAt > this.now()) {
1365
- return this.tree.parents;
1575
+ return this.tree;
1366
1576
  }
1367
1577
  if (this.pendingTree) {
1368
1578
  return this.pendingTree;
@@ -1399,7 +1609,7 @@ var LocalAuthorizer = class {
1399
1609
  if (held) {
1400
1610
  this.stats.treeNotModified += 1;
1401
1611
  this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1402
- return held.parents;
1612
+ return this.tree;
1403
1613
  }
1404
1614
  return this.keepTree([], null);
1405
1615
  }
@@ -1460,19 +1670,26 @@ var LocalAuthorizer = class {
1460
1670
  ids.set(edge.id, edge.id);
1461
1671
  }
1462
1672
  const parents = /* @__PURE__ */ new Map();
1673
+ const children = /* @__PURE__ */ new Map();
1463
1674
  for (const edge of edges) {
1464
- const parent = edge.parent_node_id ?? null;
1465
- parents.set(
1466
- edge.id,
1467
- parent === null ? null : ids.get(parent) ?? parent
1468
- );
1675
+ const parent = edge.parent_node_id === null || edge.parent_node_id === void 0 ? null : ids.get(edge.parent_node_id) ?? edge.parent_node_id;
1676
+ parents.set(edge.id, parent);
1677
+ if (parent !== null) {
1678
+ const siblings = children.get(parent);
1679
+ if (siblings) {
1680
+ siblings.push(edge.id);
1681
+ } else {
1682
+ children.set(parent, [edge.id]);
1683
+ }
1684
+ }
1469
1685
  }
1470
1686
  this.tree = {
1471
1687
  parents,
1688
+ children,
1472
1689
  etag,
1473
1690
  expiresAt: this.now() + this.ttlMs
1474
1691
  };
1475
- return parents;
1692
+ return this.tree;
1476
1693
  }
1477
1694
  };
1478
1695
  function spansPages(first) {
@@ -1915,6 +2132,7 @@ exports.CanopyClient = CanopyClient;
1915
2132
  exports.CanopyConnectionError = CanopyConnectionError;
1916
2133
  exports.CanopyError = CanopyError;
1917
2134
  exports.CanopyTokenError = CanopyTokenError;
2135
+ exports.DEFAULT_MAX_NODES = DEFAULT_MAX_NODES;
1918
2136
  exports.Identities = Identities;
1919
2137
  exports.LocalAuthorizer = LocalAuthorizer;
1920
2138
  exports.MAX_TTL_MS = MAX_TTL_MS;